├── VERSION
├── NOTICE
├── .gitmodules
├── .yardopts
├── .gitignore
├── .github
├── PULL_REQUEST_TEMPLATE.md
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── documentation.yml
│ ├── feature-request.yml
│ └── bug-report.yml
└── workflows
│ ├── handle-stale-discussions.yml
│ ├── closed-issue-message.yml
│ ├── ci.yml
│ └── stale_issues.yml
├── lib
├── aws
│ └── session_store
│ │ └── dynamo_db
│ │ ├── locking.rb
│ │ ├── errors.rb
│ │ ├── locking
│ │ ├── null.rb
│ │ ├── base.rb
│ │ └── pessimistic.rb
│ │ ├── errors
│ │ ├── base_handler.rb
│ │ └── default_handler.rb
│ │ ├── table.rb
│ │ ├── garbage_collection.rb
│ │ ├── rack_middleware.rb
│ │ └── configuration.rb
└── aws-sessionstore-dynamodb.rb
├── CODE_OF_CONDUCT.md
├── doc-src
└── templates
│ └── default
│ └── layout
│ └── html
│ ├── footer.erb
│ └── layout.erb
├── Gemfile
├── .rubocop.yml
├── spec
├── spec_helper.rb
└── aws
│ └── session_store
│ └── dynamo_db
│ ├── table_spec.rb
│ ├── locking
│ └── threaded_sessions_spec.rb
│ ├── error
│ └── default_handler_spec.rb
│ ├── garbage_collection_spec.rb
│ ├── rack_middleware_integration_spec.rb
│ ├── configuration_spec.rb
│ └── rack_middleware_spec.rb
├── Rakefile
├── aws-sessionstore-dynamodb.gemspec
├── CHANGELOG.md
├── CONTRIBUTING.md
├── README.md
└── LICENSE
/VERSION:
--------------------------------------------------------------------------------
1 | 3.0.1
2 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "tasks/release"]
2 | path = tasks/release
3 | url = git@github.com:aws/aws-sdk-ruby-release-tools.git
4 |
--------------------------------------------------------------------------------
/.yardopts:
--------------------------------------------------------------------------------
1 | --title 'AWS DynamoDB Session Store'
2 | --template-path doc-src/templates
3 | --hide-api private
4 | --plugin sitemap
5 | --markup markdown
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .byebug_history
3 |
4 | Gemfile.lock
5 | gemfiles/*.gemfile.lock
6 | *.gem
7 |
8 | coverage/
9 | .yardoc/
10 | doc/
11 | docs.zip
12 | .release/
13 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | *Issue #, if available:*
2 |
3 | *Description of changes:*
4 |
5 |
6 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
7 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | ---
2 | blank_issues_enabled: false
3 | contact_links:
4 | - name: 💬 General Question
5 | url: https://github.com/aws/aws-sdk-ruby/discussions/categories/q-a
6 | about: Please ask and answer questions as a discussion thread
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/locking.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB
4 | # @api private
5 | module Locking; end
6 | end
7 |
8 | require_relative 'locking/base'
9 | require_relative 'locking/null'
10 | require_relative 'locking/pessimistic'
11 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
4 | opensource-codeofconduct@amazon.com with any additional questions or comments.
5 |
--------------------------------------------------------------------------------
/doc-src/templates/default/layout/html/footer.erb:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gemspec
6 |
7 | gem 'rake', require: false
8 |
9 | group :development do
10 | gem 'byebug', platforms: :ruby
11 | gem 'rubocop'
12 | end
13 |
14 | group :docs do
15 | gem 'yard'
16 | gem 'yard-sitemap', '~> 1.0'
17 | end
18 |
19 | group :release do
20 | gem 'octokit'
21 | end
22 |
23 | group :test do
24 | gem 'rack-test'
25 | gem 'rails'
26 | gem 'rexml'
27 | gem 'rspec'
28 | gem 'simplecov'
29 | end
30 |
--------------------------------------------------------------------------------
/.github/workflows/handle-stale-discussions.yml:
--------------------------------------------------------------------------------
1 | name: HandleStaleDiscussions
2 | on:
3 | schedule:
4 | - cron: '0 */4 * * *'
5 | discussion_comment:
6 | types: [created]
7 |
8 | jobs:
9 | handle-stale-discussions:
10 | name: Handle stale discussions
11 | runs-on: ubuntu-latest
12 | permissions:
13 | discussions: write
14 | steps:
15 | - name: Stale discussions action
16 | uses: aws-github-ops/handle-stale-discussions@v1
17 | env:
18 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
--------------------------------------------------------------------------------
/lib/aws-sessionstore-dynamodb.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws
4 | module SessionStore
5 | # Namespace for DynamoDB rack session storage.
6 | module DynamoDB
7 | VERSION = File.read(File.expand_path('../VERSION', __dir__)).strip
8 | end
9 | end
10 | end
11 |
12 | require_relative 'aws/session_store/dynamo_db/configuration'
13 | require_relative 'aws/session_store/dynamo_db/errors'
14 | require_relative 'aws/session_store/dynamo_db/garbage_collection'
15 | require_relative 'aws/session_store/dynamo_db/locking'
16 | require_relative 'aws/session_store/dynamo_db/rack_middleware'
17 | require_relative 'aws/session_store/dynamo_db/table'
18 |
--------------------------------------------------------------------------------
/.github/workflows/closed-issue-message.yml:
--------------------------------------------------------------------------------
1 | name: Closed Issue Message
2 | on:
3 | issues:
4 | types: [closed]
5 | permissions: {}
6 | jobs:
7 | auto_comment:
8 | permissions:
9 | issues: write # to comment on issues
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: aws-actions/closed-issue-message@v1
13 | with:
14 | # These inputs are both required
15 | repo-token: "${{ secrets.GITHUB_TOKEN }}"
16 | message: |
17 | This issue is now closed. Comments on closed issues are hard for our team to see.
18 | If you need more assistance, please open a new issue that references this one.
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/documentation.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: "📕 Documentation Issue"
3 | description: Report an issue in the API Reference documentation or Developer Guide
4 | title: "(short issue description)"
5 | labels: [documentation, needs-triage]
6 | assignees: []
7 | body:
8 | - type: textarea
9 | id: description
10 | attributes:
11 | label: Describe the issue
12 | description: A clear and concise description of the issue.
13 | validations:
14 | required: true
15 |
16 | - type: textarea
17 | id: links
18 | attributes:
19 | label: Links
20 | description: |
21 | Include links to affected documentation page(s).
22 | validations:
23 | required: true
24 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | # inherit_from: .rubocop_todo.yml
2 |
3 | AllCops:
4 | Exclude:
5 | - 'tasks/release/**/*'
6 | - 'test/dummy/db/migrate'
7 | NewCops: enable
8 | SuggestExtensions: false
9 | TargetRubyVersion: 2.7
10 |
11 | Gemspec/RequireMFA:
12 | Enabled: false
13 |
14 | Metrics/BlockLength:
15 | Exclude:
16 | - 'spec/**/*'
17 |
18 | Metrics/ClassLength:
19 | Max: 150
20 |
21 | Naming/AccessorMethodName:
22 | Enabled: false
23 |
24 | Naming/FileName:
25 | Exclude:
26 | - 'lib/aws-sessionstore-dynamodb.rb'
27 |
28 | Security/MarshalLoad:
29 | Exclude:
30 | - 'lib/aws/session_store/dynamo_db/locking/base.rb'
31 |
32 | Style/ClassAndModuleChildren:
33 | Enabled: false
34 |
35 | Style/GlobalVars:
36 | AllowedVariables:
37 | - $VERSION
38 | - $REPO_ROOT
39 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'simplecov'
4 | SimpleCov.start { add_filter 'spec' }
5 |
6 | $LOAD_PATH << File.join(File.dirname(File.dirname(__FILE__)), 'lib')
7 |
8 | require 'rack/test'
9 | require 'rspec'
10 | require 'aws-sessionstore-dynamodb'
11 |
12 | # Default Rack application
13 | class MultiplierApplication
14 | def call(env)
15 | if env['rack.session'][:multiplier]
16 | env['rack.session'][:multiplier] *= 2
17 | else
18 | env['rack.session'][:multiplier] = 1
19 | end
20 | [200, { 'Content-Type' => 'text/plain' }, ['All good!']]
21 | end
22 | end
23 |
24 | RSpec.configure do |c|
25 | c.before(:all, integration: true) do
26 | opts = { table_name: 'sessionstore-integration-test' }
27 | Aws::SessionStore::DynamoDB::Table.create_table(opts)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/errors.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB::Errors
4 | # This error is raised when no secret key is provided.
5 | class MissingSecretKeyError < RuntimeError
6 | def initialize(msg = 'No secret key provided!')
7 | super
8 | end
9 | end
10 |
11 | # This error is raised when an invalid session ID is provided.
12 | class InvalidIDError < RuntimeError
13 | def initialize(msg = 'Corrupt Session ID!')
14 | super
15 | end
16 | end
17 |
18 | # This error is raised when the maximum time spent to acquire lock has been exceeded.
19 | class LockWaitTimeoutError < RuntimeError
20 | def initialize(msg = 'Maximum time spent to acquire lock has been exceeded!')
21 | super
22 | end
23 | end
24 | end
25 |
26 | require_relative 'errors/base_handler'
27 | require_relative 'errors/default_handler'
28 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rspec/core/rake_task'
4 | require 'rake/testtask'
5 | require 'rubocop/rake_task'
6 |
7 | $REPO_ROOT = File.dirname(__FILE__)
8 | $LOAD_PATH.unshift(File.join($REPO_ROOT, 'lib'))
9 | $VERSION = ENV['VERSION'] || File.read(File.join($REPO_ROOT, 'VERSION')).strip
10 |
11 | Dir.glob('**/*.rake').each do |task_file|
12 | load task_file
13 | end
14 |
15 | desc 'Runs unit tests'
16 | RSpec::Core::RakeTask.new do |t|
17 | t.rspec_opts = '--tag ~integration --format documentation'
18 | end
19 |
20 | desc 'Runs integration tests'
21 | RSpec::Core::RakeTask.new('spec:integration') do |t|
22 | t.rspec_opts = '--tag integration --format documentation'
23 | end
24 |
25 | desc 'Runs unit and integration tests'
26 | task 'test' => [:spec, 'spec:integration']
27 |
28 | RuboCop::RakeTask.new
29 |
30 | task default: :spec
31 | task 'release:test' => [:spec, 'spec:integration']
32 |
--------------------------------------------------------------------------------
/doc-src/templates/default/layout/html/layout.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= erb(:headers) %>
5 |
6 |
17 |
22 |
23 |
24 |
25 | <%= yieldall %>
26 |
27 |
28 | <%= erb(:footer) %>
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/locking/null.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB::Locking
4 | # This class gets and sets sessions
5 | # without a locking strategy.
6 | class Null < Aws::SessionStore::DynamoDB::Locking::Base
7 | # Retrieve session if it exists from the database by id.
8 | # Unpack the data once retrieved from the database.
9 | def get_session_data(env, sid)
10 | handle_error(env) do
11 | result = @config.dynamo_db_client.get_item(get_session_opts(sid))
12 | extract_data(env, result)
13 | end
14 | end
15 |
16 | # @return [Hash] Options for getting session.
17 | def get_session_opts(sid)
18 | merge_all(table_opts(sid), attr_opts)
19 | end
20 |
21 | # @return [String] Session data.
22 | def extract_data(env, result = nil)
23 | env['rack.initial_data'] = result[:item]['data'] if result[:item]
24 | unpack_data(result[:item]['data']) if result[:item]
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/aws-sessionstore-dynamodb.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | version = File.read(File.expand_path('VERSION', __dir__)).strip
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = 'aws-sessionstore-dynamodb'
7 | spec.version = version
8 | spec.author = 'Amazon Web Services'
9 | spec.email = ['aws-dr-rubygems@amazon.com']
10 | spec.summary = 'Amazon DynamoDB Session Store for Rack web applications.'
11 | spec.description = 'The Amazon DynamoDB Session Store handles sessions ' \
12 | 'for Rack web applications using a DynamoDB backend.'
13 | spec.homepage = 'https://github.com/aws/aws-sessionstore-dynamodb-ruby'
14 | spec.license = 'Apache-2.0'
15 | spec.files = Dir['LICENSE', 'CHANGELOG.md', 'VERSION', 'lib/**/*']
16 |
17 | spec.add_dependency 'rack', '~> 3'
18 | spec.add_dependency 'rack-session', '~> 2'
19 |
20 | # Require 1.85.0 for user_agent_frameworks config
21 | spec.add_dependency 'aws-sdk-dynamodb', '~> 1', '>= 1.85.0'
22 |
23 | spec.required_ruby_version = '>= 2.7'
24 | end
25 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/table_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | module Aws::SessionStore::DynamoDB
6 | describe Table, integration: true do
7 | let(:table_name) { "sessionstore-integration-test-#{Time.now.to_i}" }
8 | let(:options) { { table_name: table_name } }
9 | let(:logger) { Logger.new(IO::NULL) }
10 |
11 | before { allow(Table).to receive(:logger).and_return(logger) }
12 |
13 | it 'Creates and deletes a new table' do
14 | expect(logger).to receive(:info)
15 | .with("Table #{table_name} created, waiting for activation...")
16 | expect(logger).to receive(:info)
17 | .with("Table #{table_name} is now ready to use.")
18 | Table.create_table(options)
19 |
20 | # second attempt should warn
21 | expect(logger).to receive(:warn)
22 | .with("Table #{table_name} already exists, skipping creation.")
23 | Table.create_table(options)
24 |
25 | expect(logger).to receive(:info)
26 | .with("Table #{table_name} deleted.")
27 | Table.delete_table(options)
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/errors/base_handler.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB::Errors
4 | # BaseErrorHandler provides an interface for error handlers
5 | # that can be passed in to {Aws::SessionStore::DynamoDB::RackMiddleware}.
6 | # Each error handler must implement a handle_error method.
7 | #
8 | # @example Sample ErrorHandler class
9 | # class MyErrorHandler < BaseHandler
10 | # # Handles error passed in
11 | # def handle_error(e, env = {})
12 | # File.open(path_to_file, 'w') {|f| f.write(e.message) }
13 | # false
14 | # end
15 | # end
16 | class BaseHandler
17 | # An error and an environment (optionally) will be passed in to
18 | # this method and it will determine how to deal
19 | # with the error.
20 | # Must return false if you have handled the error but are not re-raising the
21 | # error up the stack.
22 | # You may reraise the error passed.
23 | #
24 | # @param [Aws::DynamoDB::Errors::Base] error error passed in from
25 | # Aws::SessionStore::DynamoDB::RackMiddleware.
26 | # @param [Rack::Request::Environment,nil] env Rack environment
27 | # @return [false] If exception was handled and will not reraise exception.
28 | # @raise [Aws::DynamoDB::Errors] If error has be re-raised.
29 | def handle_error(error, env = {})
30 | raise NotImplementedError
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | env:
13 | ruby_version: 3.4
14 |
15 | jobs:
16 | test:
17 | runs-on: ubuntu-latest
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | ruby: [2.7, '3.0', 3.1, 3.2, 3.3, 3.4, jruby-9.4, jruby-10.0]
22 | services:
23 | dynamodb:
24 | image: amazon/dynamodb-local:latest
25 | ports:
26 | - 8000:8000
27 | env:
28 | AWS_REGION: us-east-1
29 | AWS_ACCESS_KEY_ID: akid
30 | AWS_SECRET_ACCESS_KEY: secret
31 | AWS_ENDPOINT_URL_DYNAMODB: http://localhost:8000
32 |
33 | steps:
34 | - uses: actions/checkout@v4
35 |
36 | - name: Setup Ruby
37 | uses: ruby/setup-ruby@v1
38 | with:
39 | ruby-version: ${{ matrix.ruby }}
40 |
41 | - name: Install gems
42 | run: |
43 | bundle config set --local with 'test'
44 | bundle install
45 |
46 | - name: Tests
47 | run: bundle exec rake test
48 |
49 | rubocop:
50 | runs-on: ubuntu-latest
51 |
52 | steps:
53 | - uses: actions/checkout@v4
54 |
55 | - name: Setup Ruby
56 | uses: ruby/setup-ruby@v1
57 | with:
58 | ruby-version: ${{ env.ruby_version }}
59 |
60 | - name: Install gems
61 | run: |
62 | bundle config set --local with 'development'
63 | bundle install
64 |
65 | - name: Rubocop
66 | run: bundle exec rake rubocop
67 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/errors/default_handler.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB::Errors
4 | # This class handles errors raised from DynamoDB.
5 | class DefaultHandler < Aws::SessionStore::DynamoDB::Errors::BaseHandler
6 | # Array of errors that will always be passed up the Rack stack.
7 | HARD_ERRORS = [
8 | Aws::DynamoDB::Errors::ResourceNotFoundException,
9 | Aws::DynamoDB::Errors::ConditionalCheckFailedException,
10 | Aws::SessionStore::DynamoDB::Errors::MissingSecretKeyError,
11 | Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError
12 | ].freeze
13 |
14 | # Determines behavior of DefaultErrorHandler
15 | # @param [true] raise_errors Pass all errors up the Rack stack.
16 | def initialize(raise_errors)
17 | super()
18 | @raise_errors = raise_errors
19 | end
20 |
21 | # Raises {HARD_ERRORS} up the Rack stack.
22 | # Places all other errors in Racks error stream.
23 | def handle_error(error, env = {})
24 | raise error if HARD_ERRORS.include?(error.class) || @raise_errors
25 |
26 | store_error(error, env)
27 | false
28 | end
29 |
30 | # Sends error to error stream
31 | def store_error(error, env = {})
32 | env['rack.errors'].puts(errors_string(error)) if env
33 | end
34 |
35 | # Returns string to be placed in error stream
36 | def errors_string(error)
37 | str = []
38 | str << "Exception occurred: #{error.message}"
39 | str << 'Stack trace:'
40 | str += error.backtrace.map { |l| " #{l}" }
41 | str.join("\n")
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: 🚀 Feature Request
3 | description: Suggest an idea for this project
4 | title: "(short issue description)"
5 | labels: [feature-request, needs-triage]
6 | assignees: []
7 | body:
8 | - type: textarea
9 | id: description
10 | attributes:
11 | label: Describe the feature
12 | description: A clear and concise description of the feature you are proposing.
13 | validations:
14 | required: true
15 | - type: textarea
16 | id: use-case
17 | attributes:
18 | label: Use Case
19 | description: |
20 | Why do you need this feature? For example: "I'm always frustrated when..."
21 | validations:
22 | required: true
23 | - type: textarea
24 | id: solution
25 | attributes:
26 | label: Proposed Solution
27 | description: |
28 | Suggest how to implement the addition or change. Please include prototype/workaround/sketch/reference implementation.
29 | validations:
30 | required: false
31 | - type: textarea
32 | id: other
33 | attributes:
34 | label: Other Information
35 | description: |
36 | Any alternative solutions or features you considered, a more detailed explanation, stack traces, related issues, links for context, etc.
37 | validations:
38 | required: false
39 | - type: checkboxes
40 | id: ack
41 | attributes:
42 | label: Acknowledgements
43 | options:
44 | - label: I may be able to implement this feature request
45 | required: false
46 | - label: This feature might incur a breaking change
47 | required: false
48 | - type: input
49 | id: version
50 | attributes:
51 | label: version of the aws-sessionstore-dynamodb-ruby Gem
52 | validations:
53 | required: true
54 | - type: input
55 | id: environment
56 | attributes:
57 | label: Environment details (OS name and version, etc.)
58 | validations:
59 | required: true
60 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Unreleased Changes
2 | ------------------
3 |
4 | 3.0.1 (2024-11-16)
5 | ------------------
6 |
7 | * Issue - `Configuration` now takes environment variables with precedence over YAML configuration.
8 |
9 | * Issue - Use ENV variables that are prefixed by `AWS_`.
10 |
11 | 3.0.0 (2024-10-29)
12 | ------------------
13 |
14 | * Feature - Uses `rack ~> 3` as the minimum.
15 |
16 | * Feature - Drop support for Ruby 2.5 and 2.6.
17 |
18 | * Feature - Support additional configuration options through ENV.
19 |
20 | * Feature - Moves error classes into the `Errors` module.
21 |
22 | * Issue - Set `RackMiddleware`'s `#find_session`, `#write_session`, and `#delete_session` as public.
23 |
24 | * Issue - Validate `Configuration` has a secret key on `RackMiddleware#initialize` instead of on `#find_session`.
25 |
26 | 2.2.0 (2024-01-25)
27 | ------------------
28 |
29 | * Feature - Drop support for Ruby 2.3 and 2.4.
30 |
31 | * Issue - Relax `rack` dependency to allow version 3. Adds `rack-session` to the gemspec.
32 |
33 | 2.1.0 (2023-06-02)
34 | ------------------
35 |
36 | * Feature - Improve User-Agent tracking and bump minimum DynamoDB version.
37 |
38 | 2.0.1 (2020-11-16)
39 | ------------------
40 |
41 | * Issue - Expose `:config` in `RackMiddleware` and `:config_file` in `Configuration`.
42 |
43 | * Issue - V2 of this release was still loading SDK V1 credential keys. This removes support for client options specified in YAML configuration (behavior change). Instead, construct `Aws::DynamoDB::Client` and use the `dynamo_db_client` option.
44 |
45 | 2.0.0 (2020-11-11)
46 | ------------------
47 |
48 | * Remove Rails support (moved to the `aws-sdk-rails` gem).
49 |
50 | * Use V3 of Ruby SDK
51 |
52 | * Fix a `dynamo_db.scan()` incompatibility from the V1 -> V2 upgrade in the garbage collector.
53 |
54 | 1.0.0 (2017-08-14)
55 | ------------------
56 |
57 | * Use V2 of Ruby SDK (no history)
58 |
59 |
60 | 0.5.1 (2015-08-26)
61 | ------------------
62 |
63 | * Bug Fix (no history)
64 |
65 | 0.5.0 (2013-08-27)
66 | ------------------
67 |
68 | * Initial Release (no history)
69 |
--------------------------------------------------------------------------------
/.github/workflows/stale_issues.yml:
--------------------------------------------------------------------------------
1 | name: "Close stale issues"
2 |
3 | # Controls when the action will run.
4 | on:
5 | schedule:
6 | - cron: "0 0 * * *"
7 |
8 | permissions: {}
9 | jobs:
10 | cleanup:
11 | permissions:
12 | issues: write # to label, comment and close issues
13 | pull-requests: write # to label, comment and close pull requests
14 |
15 | runs-on: ubuntu-latest
16 | name: Stale issue job
17 | steps:
18 | - uses: aws-actions/stale-issue-cleanup@v6
19 | with:
20 | # Setting messages to an empty string will cause the automation to skip
21 | # that category
22 | ancient-issue-message: Greetings! We’re closing this issue because it has been open a long time and hasn’t been updated in a while and may not be getting the attention it deserves. We encourage you to check if this is still an issue in the latest release and if you find that this is still a problem, please feel free to comment or open a new issue.
23 | stale-issue-message: This issue has not received a response in 1 week. If you still think there is a problem, please leave a comment to avoid the issue from automatically closing.
24 | stale-pr-message: This PR has not received a response in 1 week. If you still think there is a problem, please leave a comment to avoid the PR from automatically closing.
25 | # These labels are required
26 | stale-issue-label: closing-soon
27 | exempt-issue-label: no-autoclose
28 | stale-pr-label: closing-soon
29 | exempt-pr-label: pr/needs-review
30 | response-requested-label: response-requested
31 |
32 | # Don't set closed-for-staleness label to skip closing very old issues
33 | # regardless of label
34 | closed-for-staleness-label: closed-for-staleness
35 |
36 | # Issue timing
37 | days-before-stale: 10
38 | days-before-close: 4
39 | days-before-ancient: 36500
40 |
41 | # If you don't want to mark a issue as being ancient based on a
42 | # threshold of "upvotes", you can set this here. An "upvote" is
43 | # the total number of +1, heart, hooray, and rocket reactions
44 | # on an issue.
45 | minimum-upvotes-to-exempt: 10
46 |
47 | repo-token: ${{ secrets.GITHUB_TOKEN }}
48 | # loglevel: DEBUG
49 | # Set dry-run to true to not perform label or close actions.
50 | # dry-run: true
51 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/locking/threaded_sessions_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe Aws::SessionStore::DynamoDB::RackMiddleware, integration: true do
6 | include Rack::Test::Methods
7 |
8 | def thread(mul_val, time, check)
9 | Thread.new do
10 | sleep(time)
11 | get '/'
12 | expect(last_request.session[:multiplier]).to eq(mul_val) if check
13 | end
14 | end
15 |
16 | def thread_exception(error)
17 | Thread.new { expect { get '/' }.to raise_error(error) }
18 | end
19 |
20 | def update_item_mock(options, update_method)
21 | sleep(0.50) if options[:return_values] == 'UPDATED_NEW' && options.key?(:expected)
22 | update_method.call(options)
23 | end
24 |
25 | let(:base_app) { MultiplierApplication.new }
26 | let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, @options) }
27 |
28 | before do
29 | @options = Aws::SessionStore::DynamoDB::Configuration.new.to_hash
30 | @options[:table_name] = 'sessionstore-integration-test'
31 | @options[:enable_locking] = true
32 | @options[:secret_key] = 'watermelon_smiles'
33 |
34 | update_method = @options[:dynamo_db_client].method(:update_item)
35 | expect(@options[:dynamo_db_client]).to receive(:update_item).at_least(:once) do |options|
36 | update_item_mock(options, update_method)
37 | end
38 | end
39 |
40 | it 'should wait for lock' do
41 | @options[:lock_expiry_time] = 2000
42 |
43 | get '/'
44 | expect(last_request.session[:multiplier]).to eq(1)
45 |
46 | t1 = thread(2, 0, false)
47 | t2 = thread(4, 0.25, true)
48 | t1.join
49 | t2.join
50 | end
51 |
52 | it 'should bust lock' do
53 | @options[:lock_expiry_time] = 100
54 |
55 | get '/'
56 | expect(last_request.session[:multiplier]).to eq(1)
57 |
58 | t1 = thread_exception(Aws::DynamoDB::Errors::ConditionalCheckFailedException)
59 | t2 = thread(2, 0.25, true)
60 | t1.join
61 | t2.join
62 | end
63 |
64 | it 'should throw exceeded time spent aquiring lock error' do
65 | @options[:lock_expiry_time] = 1000
66 | @options[:lock_retry_delay] = 100
67 | @options[:lock_max_wait_time] = 0.25
68 |
69 | get '/'
70 | expect(last_request.session[:multiplier]).to eq(1)
71 |
72 | t1 = thread(2, 0, false)
73 | sleep(0.25)
74 | t2 = thread_exception(Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError)
75 | t1.join
76 | t2.join
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/error/default_handler_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe Aws::SessionStore::DynamoDB do
6 | include Rack::Test::Methods
7 |
8 | let(:missing_key_error) { Aws::SessionStore::DynamoDB::Errors::MissingSecretKeyError }
9 | let(:resource_error_msg) { 'The Resource is not found' }
10 | let(:resource_error) do
11 | Aws::DynamoDB::Errors::ResourceNotFoundException.new(double('Seahorse::Client::RequestContext'), resource_error_msg)
12 | end
13 | let(:key_error_msg) { 'The provided key element does not match the schema' }
14 | let(:key_error) do
15 | Aws::DynamoDB::Errors::ValidationException.new(double('Seahorse::Client::RequestContext'), key_error_msg)
16 | end
17 | let(:client_error_msg) { 'Unrecognized Client' }
18 | let(:client_error) do
19 | Aws::DynamoDB::Errors::UnrecognizedClientException.new(double('Seahorse::Client::RequestContext'), client_error_msg)
20 | end
21 |
22 | let(:options) do
23 | { dynamo_db_client: client, secret_key: 'meltingbutter' }
24 | end
25 |
26 | let(:base_app) { MultiplierApplication.new }
27 | let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, options) }
28 | let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) }
29 |
30 | it 'raises error for missing secret key' do
31 | allow(client).to receive(:update_item).and_raise(missing_key_error)
32 | expect { get '/' }.to raise_error(missing_key_error)
33 | end
34 |
35 | it 'catches exception for inaccurate table name and raises error ' do
36 | allow(client).to receive(:update_item).and_raise(resource_error)
37 | expect { get '/' }.to raise_error(resource_error)
38 | end
39 |
40 | it 'catches exception for inaccurate table key' do
41 | allow(client).to receive(:update_item).and_raise(key_error)
42 | allow(client).to receive(:get_item).and_raise(key_error)
43 |
44 | get '/'
45 | expect(last_request.env['rack.errors'].string).to include(key_error_msg)
46 | end
47 |
48 | context 'raise_error is true' do
49 | before do
50 | options[:raise_errors] = true
51 | end
52 |
53 | it 'raises all errors' do
54 | allow(client).to receive(:update_item).and_raise(client_error)
55 | expect { get '/' }.to raise_error(client_error)
56 | end
57 |
58 | it 'catches exceptions for inaccurate table key and raises error' do
59 | allow(client).to receive(:update_item).and_raise(key_error)
60 | expect { get '/' }.to raise_error(key_error)
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: "🐛 Bug Report"
3 | description: Report a bug
4 | title: "(short issue description)"
5 | labels: [bug, needs-triage]
6 | assignees: []
7 | body:
8 | - type: textarea
9 | id: description
10 | attributes:
11 | label: Describe the bug
12 | description: What is the problem? A clear and concise description of the bug.
13 | validations:
14 | required: true
15 | - type: textarea
16 | id: expected
17 | attributes:
18 | label: Expected Behavior
19 | description: |
20 | What did you expect to happen?
21 | validations:
22 | required: true
23 | - type: textarea
24 | id: current
25 | attributes:
26 | label: Current Behavior
27 | description: |
28 | What actually happened?
29 |
30 | Please include full errors, uncaught exceptions, stack traces, and relevant logs.
31 | If service responses are relevant, please include wire logs.
32 | validations:
33 | required: true
34 | - type: textarea
35 | id: reproduction
36 | attributes:
37 | label: Reproduction Steps
38 | description: |
39 | Provide a self-contained, concise snippet of code that can be used to reproduce the issue.
40 | For more complex issues provide a repo with the smallest sample that reproduces the bug.
41 |
42 | Avoid including business logic or unrelated code, it makes diagnosis more difficult.
43 | The code sample should be an SSCCE. See http://sscce.org/ for details. In short, please provide a code sample that we can copy/paste, run and reproduce.
44 | validations:
45 | required: true
46 | - type: textarea
47 | id: solution
48 | attributes:
49 | label: Possible Solution
50 | description: |
51 | Suggest a fix/reason for the bug
52 | validations:
53 | required: false
54 | - type: textarea
55 | id: context
56 | attributes:
57 | label: Additional Information/Context
58 | description: |
59 | Anything else that might be relevant for troubleshooting this bug. Providing context helps us come up with a solution that is most useful in the real world.
60 | validations:
61 | required: false
62 | - type: input
63 | id: aws-sessionstore-dynamodb-ruby version
64 | attributes:
65 | label: Gem version used
66 | validations:
67 | required: true
68 | - type: input
69 | id: environment
70 | attributes:
71 | label: Environment details (Version of Ruby, OS environment)
72 | validations:
73 | required: true
74 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'aws-sdk-dynamodb'
4 | require 'logger'
5 |
6 | module Aws::SessionStore::DynamoDB
7 | # This module provides a way to create and delete a session table.
8 | module Table
9 | class << self
10 | # Creates a session table.
11 | # @option (see Configuration#initialize)
12 | def create_table(options = {})
13 | config = load_config(options)
14 | config.dynamo_db_client.create_table(create_opts(config))
15 | logger.info "Table #{config.table_name} created, waiting for activation..."
16 | config.dynamo_db_client.wait_until(:table_exists, table_name: config.table_name)
17 | logger.info "Table #{config.table_name} is now ready to use."
18 | rescue Aws::DynamoDB::Errors::ResourceInUseException
19 | logger.warn "Table #{config.table_name} already exists, skipping creation."
20 | end
21 |
22 | # Deletes a session table.
23 | # @option (see Configuration#initialize)
24 | def delete_table(options = {})
25 | config = load_config(options)
26 | config.dynamo_db_client.delete_table(table_name: config.table_name)
27 | config.dynamo_db_client.wait_until(:table_not_exists, table_name: config.table_name)
28 | logger.info "Table #{config.table_name} deleted."
29 | end
30 |
31 | private
32 |
33 | def logger
34 | @logger ||= Logger.new($stdout)
35 | end
36 |
37 | # Loads configuration options.
38 | # @option (see Configuration#initialize)
39 | def load_config(options = {})
40 | Aws::SessionStore::DynamoDB::Configuration.new(options)
41 | end
42 |
43 | def create_opts(config)
44 | properties(config.table_name, config.table_key).merge(
45 | throughput(config.read_capacity, config.write_capacity)
46 | )
47 | end
48 |
49 | # @return Properties for the session table.
50 | def properties(table_name, hash_key)
51 | attributes(hash_key).merge(schema(table_name, hash_key))
52 | end
53 |
54 | # @return [Hash] Attribute settings for creating the session table.
55 | def attributes(hash_key)
56 | {
57 | attribute_definitions: [
58 | { attribute_name: hash_key, attribute_type: 'S' }
59 | ]
60 | }
61 | end
62 |
63 | # @return Schema values for the session table.
64 | def schema(table_name, hash_key)
65 | {
66 | table_name: table_name,
67 | key_schema: [{ attribute_name: hash_key, key_type: 'HASH' }]
68 | }
69 | end
70 |
71 | # @return Throughput for the session table.
72 | def throughput(read, write)
73 | {
74 | provisioned_throughput: {
75 | read_capacity_units: read,
76 | write_capacity_units: write
77 | }
78 | }
79 | end
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
4 | documentation, we greatly value feedback and contributions from our community.
5 |
6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
7 | information to effectively respond to your bug report or contribution.
8 |
9 |
10 | ## Reporting Bugs/Feature Requests
11 |
12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features.
13 |
14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
16 |
17 | * A reproducible test case or series of steps
18 | * The version of our code being used
19 | * Any modifications you've made relevant to the bug
20 | * Anything unusual about your environment or deployment
21 |
22 |
23 | ## Contributing via Pull Requests
24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
25 |
26 | 1. You are working against the latest source on the *main* branch.
27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
29 |
30 | To send us a pull request, please:
31 |
32 | 1. Fork the repository.
33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
34 | 3. Ensure local tests pass.
35 | 4. Commit to your fork using clear commit messages.
36 | 5. Send us a pull request, answering any default questions in the pull request interface.
37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
38 |
39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
41 |
42 |
43 | ## Finding contributions to work on
44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
45 |
46 |
47 | ## Code of Conduct
48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
50 | opensource-codeofconduct@amazon.com with any additional questions or comments.
51 |
52 |
53 | ## Security issue notifications
54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
55 |
56 |
57 | ## Licensing
58 |
59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
60 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/garbage_collection_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | describe Aws::SessionStore::DynamoDB::GarbageCollection do
6 | def items(min, max)
7 | (min..max).map do |i|
8 | { 'session_id' => { s: i.to_s } }
9 | end
10 | end
11 |
12 | def format_scan_result
13 | items = (31..49).map do |i|
14 | { 'session_id' => { s: i.to_s } }
15 | end
16 |
17 | items.each_with_object([]) do |item, rqst_array|
18 | rqst_array << { delete_request: { key: item } }
19 | end
20 | end
21 |
22 | def collect_garbage
23 | options = { dynamo_db_client: dynamo_db_client, max_age: 100, max_stale: 100 }
24 | Aws::SessionStore::DynamoDB::GarbageCollection.collect_garbage(options)
25 | end
26 |
27 | let(:scan_resp1) do
28 | {
29 | items: items(0, 49),
30 | count: 50,
31 | scanned_count: 1000,
32 | last_evaluated_key: {}
33 | }
34 | end
35 |
36 | let(:scan_resp2) do
37 | {
38 | items: items(0, 31),
39 | last_evaluated_key: { 'session_id' => { s: '31' } }
40 | }
41 | end
42 |
43 | let(:scan_resp3) do
44 | { items: items(31, 49), last_evaluated_key: {} }
45 | end
46 |
47 | let(:write_resp1) do
48 | { unprocessed_items: {} }
49 | end
50 |
51 | let(:write_resp2) do
52 | {
53 | unprocessed_items: {
54 | 'sessions' => [
55 | { delete_request: { key: { 'session_id' => { s: '1' } } } },
56 | { delete_request: { key: { 'session_id' => { s: '17' } } } }
57 | ]
58 | }
59 | }
60 | end
61 |
62 | let(:dynamo_db_client) { Aws::DynamoDB::Client.new(stub_responses: true) }
63 |
64 | it 'processes scan results greater than 25 and deletes in batches of 25' do
65 | expect(dynamo_db_client).to receive(:scan)
66 | .exactly(1).times.and_return(scan_resp1)
67 | expect(dynamo_db_client).to receive(:batch_write_item)
68 | .exactly(2).times.and_return(write_resp1)
69 | collect_garbage
70 | end
71 |
72 | it 'gets scan results then returns last evaluated key and resumes scanning' do
73 | expect(dynamo_db_client).to receive(:scan)
74 | .exactly(1).times.and_return(scan_resp2)
75 | expect(dynamo_db_client).to receive(:scan)
76 | .exactly(1).times.with(
77 | hash_including(exclusive_start_key: scan_resp2[:last_evaluated_key])
78 | )
79 | .and_return(scan_resp3)
80 | expect(dynamo_db_client).to receive(:batch_write_item)
81 | .exactly(3).times.and_return(write_resp1)
82 | collect_garbage
83 | end
84 |
85 | it 'it formats unprocessed_items and then batch deletes them' do
86 | expect(dynamo_db_client).to receive(:scan)
87 | .exactly(1).times.and_return(scan_resp3)
88 | expect(dynamo_db_client).to receive(:batch_write_item)
89 | .ordered
90 | .with({ request_items: { 'sessions' => format_scan_result } })
91 | .and_return(write_resp2)
92 | expect(dynamo_db_client).to receive(:batch_write_item)
93 | .ordered
94 | .with(
95 | {
96 | request_items: {
97 | 'sessions' => [
98 | { delete_request: { key: { 'session_id' => { s: '1' } } } },
99 | { delete_request: { key: { 'session_id' => { s: '17' } } } }
100 | ]
101 | }
102 | }
103 | )
104 | .and_return(write_resp1)
105 | collect_garbage
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/rack_middleware_integration_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | module Aws
6 | module SessionStore
7 | module DynamoDB
8 | describe RackMiddleware, integration: true do
9 | include Rack::Test::Methods
10 |
11 | def table_opts(sid)
12 | {
13 | table_name: config.table_name,
14 | key: { config.table_key => sid }
15 | }
16 | end
17 |
18 | def attr_opts
19 | {
20 | attributes_to_get: %w[data created_at],
21 | consistent_read: true
22 | }
23 | end
24 |
25 | def extract_time(sid)
26 | options = table_opts(sid).merge(attr_opts)
27 | Time.at(config.dynamo_db_client.get_item(options)[:item]['created_at'].to_f)
28 | end
29 |
30 | let(:options) do
31 | { table_name: 'sessionstore-integration-test', secret_key: 'watermelon_cherries' }
32 | end
33 |
34 | let(:base_app) { MultiplierApplication.new }
35 | let(:app) { RackMiddleware.new(base_app, options) }
36 | let(:config) { app.config }
37 |
38 | it 'stores session data in session object' do
39 | get '/'
40 | expect(last_request.session[:multiplier]).to eq(1)
41 | end
42 |
43 | it 'creates a new HTTP cookie when Cookie not supplied' do
44 | get '/'
45 | expect(last_response.body).to eq('All good!')
46 | expect(last_response['Set-Cookie']).to be_truthy
47 | end
48 |
49 | it 'does not rewrite Cookie if cookie previously/accuarately set' do
50 | get '/'
51 | expect(last_response['Set-Cookie']).not_to be_nil
52 |
53 | get '/'
54 | expect(last_response['Set-Cookie']).to be_nil
55 | end
56 |
57 | it 'does not set cookie when defer option is specified' do
58 | options[:defer] = true
59 | get '/'
60 | expect(last_response['Set-Cookie']).to be_nil
61 | end
62 |
63 | it 'creates new session with false/nonexistant http-cookie id' do
64 | env = {
65 | 'HTTP_COOKIE' => 'rack.session=ApplePieBlueberries',
66 | 'rack.session' => { 'multiplier' => 1 }
67 | }
68 | get '/', {}, env
69 | expect(last_response['Set-Cookie']).not_to eq('rack.session=ApplePieBlueberries')
70 | expect(last_response['Set-Cookie']).not_to be_nil
71 | end
72 |
73 | it 'expires after specified time and sets date for cookie to expire' do
74 | options[:expire_after] = 1
75 | get '/'
76 | session_cookie = last_response['Set-Cookie']
77 | sleep(1.2)
78 |
79 | get '/'
80 | expect(last_response['Set-Cookie']).not_to be_nil
81 | expect(last_response['Set-Cookie']).not_to eq(session_cookie)
82 | end
83 |
84 | it 'will not set a session cookie when defer is true' do
85 | options[:defer] = true
86 | get '/'
87 | expect(last_response['Set-Cookie']).to be_nil
88 | end
89 |
90 | it 'adds the created at attribute for a new session' do
91 | get '/'
92 | expect(last_request.env['dynamo_db.new_session']).to eq('true')
93 | sid = last_response['Set-Cookie'].split(/[;=]/)[1]
94 | time = extract_time(sid)
95 | expect(time).to be_within(2).of(Time.now)
96 |
97 | get '/'
98 | expect(last_request.env['dynamo_db.new_session']).to be_nil
99 | end
100 | end
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/garbage_collection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'aws-sdk-dynamodb'
4 |
5 | module Aws::SessionStore::DynamoDB
6 | # Collects and deletes unwanted sessions based on
7 | # their creation and update dates.
8 | module GarbageCollection
9 | class << self
10 | # Scans DynamoDB session table to find sessions that match the max age and
11 | # max stale period requirements. it then deletes all of the found sessions.
12 | # @option (see Configuration#initialize)
13 | def collect_garbage(options = {})
14 | config = load_config(options)
15 | last_key = eliminate_unwanted_sessions(config)
16 | last_key = eliminate_unwanted_sessions(config, last_key) until last_key.empty?
17 | end
18 |
19 | private
20 |
21 | # Loads configuration options.
22 | # @option (see Configuration#initialize)
23 | def load_config(options = {})
24 | Aws::SessionStore::DynamoDB::Configuration.new(options)
25 | end
26 |
27 | # Sets scan filter attributes based on attributes specified.
28 | def scan_filter(config)
29 | hash = {}
30 | hash['created_at'] = oldest_date(config.max_age) if config.max_age
31 | hash['updated_at'] = oldest_date(config.max_stale) if config.max_stale
32 | { scan_filter: hash }
33 | end
34 |
35 | # Scans and deletes batch.
36 | def eliminate_unwanted_sessions(config, last_key = nil)
37 | scan_result = scan(config, last_key)
38 | batch_delete(config, scan_result[:items])
39 | scan_result[:last_evaluated_key] || {}
40 | end
41 |
42 | # Scans the table for sessions matching the max age and
43 | # max stale time specified.
44 | def scan(config, last_item = nil)
45 | options = scan_opts(config)
46 | options = options.merge(start_key(last_item)) if last_item
47 | config.dynamo_db_client.scan(options)
48 | end
49 |
50 | # Deletes the batch gotten from the scan result.
51 | def batch_delete(config, items)
52 | loop do
53 | subset = items.shift(25)
54 | sub_batch = write(subset)
55 | process!(config, sub_batch)
56 | break if subset.empty?
57 | end
58 | end
59 |
60 | # Turns array into correct format to be passed in to
61 | # a delete request.
62 | def write(sub_batch)
63 | sub_batch.each_with_object([]) do |item, rqst_array|
64 | rqst_array << { delete_request: { key: item } }
65 | end
66 | end
67 |
68 | # Processes pending request items.
69 | def process!(config, sub_batch)
70 | return if sub_batch.empty?
71 |
72 | opts = { request_items: { config.table_name => sub_batch } }
73 | loop do
74 | response = config.dynamo_db_client.batch_write_item(opts)
75 | opts[:request_items] = response[:unprocessed_items]
76 | break if opts[:request_items].empty?
77 | end
78 | end
79 |
80 | # Provides scan options.
81 | def scan_opts(config)
82 | table_opts(config).merge(scan_filter(config))
83 | end
84 |
85 | # Provides table options
86 | def table_opts(config)
87 | {
88 | table_name: config.table_name,
89 | attributes_to_get: [config.table_key]
90 | }
91 | end
92 |
93 | # Provides specified date attributes.
94 | def oldest_date(sec)
95 | {
96 | attribute_value_list: [n: (Time.now - sec).to_f.to_s],
97 | comparison_operator: 'LT'
98 | }
99 | end
100 |
101 | # Provides start key.
102 | def start_key(last_item)
103 | { exclusive_start_key: last_item }
104 | end
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/rack_middleware.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rack/session/abstract/id'
4 | require 'openssl'
5 | require 'aws-sdk-dynamodb'
6 |
7 | module Aws::SessionStore::DynamoDB
8 | # This class is an ID based Session Store Rack Middleware
9 | # that uses a DynamoDB backend for session storage.
10 | class RackMiddleware < Rack::Session::Abstract::Persisted
11 | # Initializes SessionStore middleware.
12 | #
13 | # @param app Rack application.
14 | # @option (see Configuration#initialize)
15 | # @raise [Aws::DynamoDB::Errors::ResourceNotFoundException] If a valid table name is not provided.
16 | # @raise [Aws::SessionStore::DynamoDB::MissingSecretKey] If a secret key is not provided.
17 | def initialize(app, options = {})
18 | super
19 | @config = Configuration.new(options)
20 | validate_config
21 | set_locking_strategy
22 | end
23 |
24 | # Get session from the database or create a new session.
25 | #
26 | # @raise [Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError] If the session
27 | # has waited too long to obtain lock.
28 | def find_session(req, sid)
29 | case verify_hmac(sid)
30 | when nil
31 | set_new_session_properties(req.env)
32 | when false
33 | handle_error { raise Errors::InvalidIDError }
34 | set_new_session_properties(req.env)
35 | else
36 | data = @lock.get_session_data(req.env, sid)
37 | [sid, data || {}]
38 | end
39 | end
40 |
41 | # Sets the session in the database after packing data.
42 | #
43 | # @return [Hash] If session has been saved.
44 | # @return [false] If session has could not be saved.
45 | def write_session(req, sid, session, options)
46 | @lock.set_session_data(req.env, sid, session, options)
47 | end
48 |
49 | # Destroys session and removes session from database.
50 | #
51 | # @return [String] return a new session id or nil if options[:drop]
52 | def delete_session(req, sid, options)
53 | @lock.delete_session(req.env, sid)
54 | generate_sid unless options[:drop]
55 | end
56 |
57 | # @return [Configuration] An instance of Configuration that is used for
58 | # this middleware.
59 | attr_reader :config
60 |
61 | private
62 |
63 | def set_locking_strategy
64 | @lock =
65 | if @config.enable_locking
66 | Aws::SessionStore::DynamoDB::Locking::Pessimistic.new(@config)
67 | else
68 | Aws::SessionStore::DynamoDB::Locking::Null.new(@config)
69 | end
70 | end
71 |
72 | def validate_config
73 | raise Errors::MissingSecretKeyError unless @config.secret_key
74 | end
75 |
76 | # Sets new session properties.
77 | def set_new_session_properties(env)
78 | env['dynamo_db.new_session'] = 'true'
79 | [generate_sid, {}]
80 | end
81 |
82 | # Each database operation is placed in this rescue wrapper.
83 | # This wrapper will call the method, rescue any exceptions and then pass
84 | # exceptions to the configured session handler.
85 | def handle_error(env = nil)
86 | yield
87 | rescue Aws::DynamoDB::Errors::Base,
88 | Aws::SessionStore::DynamoDB::Errors::InvalidIDError => e
89 | @config.error_handler.handle_error(e, env)
90 | end
91 |
92 | # Generate HMAC hash based on MD5
93 | def generate_hmac(sid, secret)
94 | OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('MD5'), secret, sid).strip
95 | end
96 |
97 | # Generate sid with HMAC hash
98 | def generate_sid(secure = @sid_secure)
99 | sid = super
100 | "#{generate_hmac(sid, @config.secret_key)}--" + sid
101 | end
102 |
103 | # Verify digest of HMACed hash
104 | #
105 | # @return [true] If the HMAC id has been verified.
106 | # @return [false] If the HMAC id has been corrupted.
107 | def verify_hmac(sid)
108 | return unless sid
109 |
110 | digest, ver_sid = sid.split('--')
111 | return false unless ver_sid
112 |
113 | digest == generate_hmac(ver_sid, @config.secret_key)
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/configuration_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 | require 'yaml'
5 |
6 | describe Aws::SessionStore::DynamoDB::Configuration do
7 | let(:defaults) do
8 | Aws::SessionStore::DynamoDB::Configuration::MEMBERS.merge(
9 | dynamo_db_client: kind_of(Aws::DynamoDB::Client),
10 | error_handler: kind_of(Aws::SessionStore::DynamoDB::Errors::DefaultHandler)
11 | )
12 | end
13 |
14 | let(:options) do
15 | {
16 | table_name: 'SessionTable',
17 | table_key: 'SessionKey',
18 | consistent_read: false,
19 | read_capacity: 20,
20 | write_capacity: 10,
21 | raise_errors: true,
22 | max_age: 7 * 60,
23 | max_stale: 7,
24 | secret_key: 'SecretKey'
25 | }
26 | end
27 |
28 | def setup_env(options)
29 | options.each do |k, v|
30 | ENV["AWS_DYNAMO_DB_SESSION_#{k.to_s.upcase}"] = v.to_s
31 | end
32 | end
33 |
34 | def teardown_env(options)
35 | options.each_key { |k| ENV.delete("AWS_DYNAMO_DB_SESSION_#{k.to_s.upcase}") }
36 | end
37 |
38 | let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) }
39 |
40 | before do
41 | allow(Aws::DynamoDB::Client).to receive(:new).and_return(client)
42 | end
43 |
44 | it 'configures defaults without runtime, ENV, or YAML options' do
45 | cfg = Aws::SessionStore::DynamoDB::Configuration.new
46 | expect(cfg.to_hash).to include(defaults)
47 | end
48 |
49 | it 'configures with YAML with precedence over defaults' do
50 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f|
51 | f << options.transform_keys(&:to_s).to_yaml
52 | f.rewind
53 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(config_file: f.path)
54 | expect(cfg.to_hash).to include(options)
55 | end
56 | end
57 |
58 | it 'configures with ENV with precedence over YAML' do
59 | setup_env(options)
60 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f|
61 | f << { table_name: 'OldTable', table_key: 'OldKey' }.transform_keys(&:to_s).to_yaml
62 | f.rewind
63 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(config_file: f.path)
64 | expect(cfg.to_hash).to include(options)
65 | ensure
66 | teardown_env(options)
67 | end
68 | end
69 |
70 | it 'configures in code with full precedence' do
71 | old = { table_name: 'OldTable', table_key: 'OldKey' }
72 | setup_env(options.merge(old))
73 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f|
74 | f << old.transform_keys(&:to_s).to_yaml
75 | f.rewind
76 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(options.merge(config_file: f.path))
77 | expect(cfg.to_hash).to include(options)
78 | ensure
79 | teardown_env(options.merge(old))
80 | end
81 | end
82 |
83 | it 'allows for config file to be configured with ENV' do
84 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f|
85 | f << options.transform_keys(&:to_s).to_yaml
86 | f.rewind
87 | ENV['AWS_DYNAMO_DB_SESSION_CONFIG_FILE'] = f.path
88 | cfg = Aws::SessionStore::DynamoDB::Configuration.new
89 | expect(cfg.to_hash).to include(options)
90 | ensure
91 | ENV.delete('AWS_DYNAMO_DB_SESSION_CONFIG_FILE')
92 | end
93 | end
94 |
95 | it 'ignores unsupported keys in ENV' do
96 | ENV['AWS_DYNAMO_DB_SESSION_DYNAMO_DB_CLIENT'] = 'Client'
97 | ENV['AWS_DYNAMO_DB_SESSION_ERROR_HANDLER'] = 'Handler'
98 | cfg = Aws::SessionStore::DynamoDB::Configuration.new
99 | expect(cfg.to_hash).to include(defaults)
100 | ensure
101 | ENV.delete('AWS_DYNAMO_DB_SESSION_DYNAMO_DB_CLIENT')
102 | ENV.delete('AWS_DYNAMO_DB_SESSION_ERROR_HANDLER')
103 | end
104 |
105 | it 'ignores unsupported keys in YAML' do
106 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f|
107 | options = { dynamo_db_client: 'Client', error_handler: 'Handler', config_file: 'File' }
108 | f << options.transform_keys(&:to_s).to_yaml
109 | f.rewind
110 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(config_file: f.path)
111 | expect(cfg.to_hash).to include(defaults.merge(config_file: f.path))
112 | end
113 | end
114 |
115 | it 'raises an exception when wrong path for file' do
116 | config_path = 'Wrong path!'
117 | runtime_opts = { config_file: config_path }.merge(options)
118 | expect { Aws::SessionStore::DynamoDB::Configuration.new(runtime_opts) }
119 | .to raise_error(Errno::ENOENT)
120 | end
121 | end
122 |
--------------------------------------------------------------------------------
/spec/aws/session_store/dynamo_db/rack_middleware_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'spec_helper'
4 |
5 | module Aws
6 | module SessionStore
7 | module DynamoDB
8 | describe RackMiddleware do
9 | include Rack::Test::Methods
10 |
11 | def ensure_data_updated(mutated_data)
12 | expect(dynamo_db_client).to receive(:update_item) do |options|
13 | if mutated_data
14 | expect(options[:attribute_updates]['data']).not_to be_nil
15 | else
16 | expect(options[:attribute_updates]['data']).to be_nil
17 | end
18 | end
19 | end
20 |
21 | let(:options) do
22 | {
23 | dynamo_db_client: dynamo_db_client,
24 | secret_key: 'watermelon_cherries'
25 | }
26 | end
27 |
28 | let(:base_app) { MultiplierApplication.new }
29 | let(:app) { RackMiddleware.new(base_app, options) }
30 |
31 | let(:sample_packed_data) do
32 | [Marshal.dump('multiplier' => 1)].pack('m*')
33 | end
34 |
35 | let(:dynamo_db_client) do
36 | Aws::DynamoDB::Client.new(
37 | stub_responses: {
38 | list_tables: { table_names: ['Sessions'] },
39 | get_item: { item: { 'data' => sample_packed_data } },
40 | update_item: { attributes: { 'created_at' => 'now' } }
41 | }
42 | )
43 | end
44 |
45 | it 'stores session data in session object' do
46 | get '/'
47 | expect(last_request.session.to_hash).to eq('multiplier' => 1)
48 | end
49 |
50 | it 'creates a new HTTP cookie when Cookie not supplied' do
51 | get '/'
52 | expect(last_response.body).to eq('All good!')
53 | expect(last_response['Set-Cookie']).to be_truthy
54 | end
55 |
56 | it 'loads/manipulates a session based on id from HTTP-Cookie' do
57 | get '/'
58 | expect(last_request.session.to_hash).to eq('multiplier' => 1)
59 |
60 | get '/'
61 | expect(last_request.session.to_hash).to eq('multiplier' => 2)
62 | end
63 |
64 | it 'does not rewrite Cookie if cookie previously/accuarately set' do
65 | get '/'
66 | expect(last_response['Set-Cookie']).not_to be_nil
67 |
68 | get '/'
69 | expect(last_response['Set-Cookie']).to be_nil
70 | end
71 |
72 | it 'does not set cookie when defer option is specifed' do
73 | options[:defer] = true
74 | get '/'
75 | expect(last_response['Set-Cookie']).to be_nil
76 | end
77 |
78 | it 'creates new session with false/nonexistant http-cookie id' do
79 | get '/'
80 | expect(last_response['Set-Cookie']).not_to eq('1234')
81 | expect(last_response['Set-Cookie']).not_to be_nil
82 | end
83 |
84 | it 'expires after specified time and sets date for cookie to expire' do
85 | options[:expire_after] = 0
86 | get '/'
87 | session_cookie = last_response['Set-Cookie']
88 |
89 | get '/'
90 | expect(last_response['Set-Cookie']).not_to be_nil
91 | expect(last_response['Set-Cookie']).not_to eq(session_cookie)
92 | end
93 |
94 | it "doesn't reset Cookie if not outside expire date" do
95 | options[:expire_after] = 3600
96 | get '/'
97 | session_cookie = last_response['Set-Cookie']
98 | get '/'
99 | expect(last_response['Set-Cookie']).to eq(session_cookie)
100 | end
101 |
102 | it 'will not set a session cookie when defer is true' do
103 | options[:defer] = true
104 | get '/'
105 | expect(last_response['Set-Cookie']).to be_nil
106 | end
107 |
108 | it 'generates sid and migrates data to new sid when renew is selected' do
109 | options[:renew] = true
110 | get '/'
111 | expect(last_request.session.to_hash).to eq('multiplier' => 1)
112 | session_cookie = last_response['Set-Cookie']
113 |
114 | get '/', 'HTTP_Cookie' => session_cookie
115 | expect(last_response['Set-Cookie']).not_to eq(session_cookie)
116 | expect(last_request.session.to_hash).to eq('multiplier' => 2)
117 | end
118 |
119 | it "doesn't resend unmutated data" do
120 | ensure_data_updated(true)
121 | options[:renew] = true
122 | get '/'
123 |
124 | ensure_data_updated(false)
125 | get '/', {}, { 'rack.session' => { 'multiplier' => nil } }
126 | end
127 | end
128 | end
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/locking/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB::Locking
4 | # Handles session management.
5 | class Base
6 | # @param [Aws::SessionStore::DynamoDB::Configuration] cfg
7 | def initialize(cfg)
8 | @config = cfg
9 | end
10 |
11 | # Updates session in database
12 | def set_session_data(env, sid, session, options = {})
13 | return false if session.empty?
14 |
15 | packed_session = pack_data(session)
16 | handle_error(env) do
17 | save_opts = update_opts(env, sid, packed_session, options)
18 | @config.dynamo_db_client.update_item(save_opts)
19 | sid
20 | end
21 | end
22 |
23 | # Retrieves session data based on id
24 | def get_session_data(env, sid)
25 | raise NotImplementedError
26 | end
27 |
28 | # Deletes session based on id
29 | def delete_session(env, sid)
30 | handle_error(env) do
31 | @config.dynamo_db_client.delete_item(delete_opts(sid))
32 | end
33 | end
34 |
35 | private
36 |
37 | # Each database operation is placed in this rescue wrapper.
38 | # This wrapper will call the method, rescue any exceptions and then pass
39 | # exceptions to the configured error handler.
40 | def handle_error(env = nil)
41 | yield
42 | rescue Aws::DynamoDB::Errors::ServiceError => e
43 | @config.error_handler.handle_error(e, env)
44 | end
45 |
46 | # @return [Hash] Options for deleting session.
47 | def delete_opts(sid)
48 | table_opts(sid)
49 | end
50 |
51 | # @return [Hash] Options for updating item in Session table.
52 | def update_opts(env, sid, session, options = {})
53 | if env['dynamo_db.new_session']
54 | save_new_opts(env, sid, session)
55 | else
56 | save_exists_opts(env, sid, session, options)
57 | end
58 | end
59 |
60 | # @return [Hash] Options for saving a new session in database.
61 | def save_new_opts(env, sid, session)
62 | attribute_opts = attr_updts(env, session, created_attr)
63 | merge_all(table_opts(sid), attribute_opts)
64 | end
65 |
66 | # @return [Hash] Options for saving an existing sesison in the database.
67 | def save_exists_opts(env, sid, session, options = {})
68 | add_attr = options[:add_attrs] || {}
69 | expected = options[:expect_attr] || {}
70 | attribute_opts = merge_all(attr_updts(env, session, add_attr), expected)
71 | merge_all(table_opts(sid), attribute_opts)
72 | end
73 |
74 | # Marshal the data.
75 | def pack_data(data)
76 | [Marshal.dump(data)].pack('m*')
77 | end
78 |
79 | # Unmarshal the data.
80 | def unpack_data(packed_data)
81 | Marshal.load(packed_data.unpack1('m*'))
82 | end
83 |
84 | # Table options for client.
85 | def table_opts(sid)
86 | {
87 | table_name: @config.table_name,
88 | key: { @config.table_key => sid }
89 | }
90 | end
91 |
92 | # Attributes to update via client.
93 | def attr_updts(env, session, add_attrs = {})
94 | data = data_unchanged?(env, session) ? {} : data_attr(session)
95 | {
96 | attribute_updates: merge_all(updated_attr, data, add_attrs, expire_attr),
97 | return_values: 'UPDATED_NEW'
98 | }
99 | end
100 |
101 | # Update client with current time attribute.
102 | def updated_at
103 | { value: Time.now.to_f.to_s, action: 'PUT' }
104 | end
105 |
106 | # Attribute for creation of session.
107 | def created_attr
108 | { 'created_at' => updated_at }
109 | end
110 |
111 | # Update client with current time + max_stale.
112 | def expire_at
113 | max_stale = @config.max_stale || 0
114 | { value: (Time.now + max_stale).to_i, action: 'PUT' }
115 | end
116 |
117 | # Attribute for TTL expiration of session.
118 | def expire_attr
119 | { 'expire_at' => expire_at }
120 | end
121 |
122 | # Attribute for updating session.
123 | def updated_attr
124 | {
125 | 'updated_at' => updated_at
126 | }
127 | end
128 |
129 | def data_attr(session)
130 | { 'data' => { value: session, action: 'PUT' } }
131 | end
132 |
133 | # Determine if data has been manipulated
134 | def data_unchanged?(env, session)
135 | return false unless env['rack.initial_data']
136 |
137 | env['rack.initial_data'] == session
138 | end
139 |
140 | # Expected attributes
141 | def expected_attributes(sid)
142 | { expected: { @config.table_key => { value: sid, exists: true } } }
143 | end
144 |
145 | # Attributes to be retrieved via client
146 | def attr_opts
147 | { attributes_to_get: ['data'],
148 | consistent_read: @config.consistent_read }
149 | end
150 |
151 | # @return [Hash] merged hash of all hashes passed in.
152 | def merge_all(*hashes)
153 | new_hash = {}
154 | hashes.each { |hash| new_hash.merge!(hash) }
155 | new_hash
156 | end
157 | end
158 | end
159 |
--------------------------------------------------------------------------------
/lib/aws/session_store/dynamo_db/locking/pessimistic.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Aws::SessionStore::DynamoDB::Locking
4 | # This class implements a pessimistic locking strategy for the
5 | # DynamoDB session handler. Sessions obtain an exclusive lock
6 | # for reads that is only released when the session is saved.
7 | class Pessimistic < Aws::SessionStore::DynamoDB::Locking::Base
8 | # Saves the session.
9 | def set_session_data(env, sid, session, options = {})
10 | super(env, sid, session, set_lock_options(env, options))
11 | end
12 |
13 | # Gets session from database and places a lock on the session
14 | # while you are reading from the database.
15 | def get_session_data(env, sid)
16 | handle_error(env) do
17 | get_session_with_lock(env, sid)
18 | end
19 | end
20 |
21 | private
22 |
23 | # Get session with implemented locking strategy.
24 | # rubocop:disable Metrics/MethodLength
25 | def get_session_with_lock(env, sid)
26 | expires_at = nil
27 | result = nil
28 | max_attempt_date = Time.now.to_f + @config.lock_max_wait_time
29 | while result.nil?
30 | exceeded_wait_time?(max_attempt_date)
31 | begin
32 | result = attempt_set_lock(sid)
33 | rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
34 | expires_at ||= get_expire_date(sid)
35 | next if expires_at.nil?
36 |
37 | result = bust_lock(sid, expires_at)
38 | wait_to_retry(result)
39 | end
40 | end
41 | get_data(env, result)
42 | end
43 | # rubocop:enable Metrics/MethodLength
44 |
45 | # Determine if session has waited too long to obtain lock.
46 | #
47 | # @raise [Error] When time for attempting to get lock has
48 | # been exceeded.
49 | def exceeded_wait_time?(max_attempt_date)
50 | lock_error = Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError
51 | raise lock_error if Time.now.to_f > max_attempt_date
52 | end
53 |
54 | # @return [Hash] Options hash for placing a lock on a session.
55 | def get_lock_time_opts(sid)
56 | merge_all(table_opts(sid), lock_opts)
57 | end
58 |
59 | # @return [Time] Time stamp for which the session was locked.
60 | def lock_time(sid)
61 | result = @config.dynamo_db_client.get_item(get_lock_time_opts(sid))
62 | result[:item]['locked_at']&.to_f
63 | end
64 |
65 | # @return [String] Session data.
66 | def get_data(env, result)
67 | lock_time = result[:attributes]['locked_at']
68 | env['locked_at'] = lock_time.to_f
69 | env['rack.initial_data'] = result[:item]['data'] if result.members.include? :item
70 | unpack_data(result[:attributes]['data'])
71 | end
72 |
73 | # Attempt to bust the lock if the expiration date has expired.
74 | def bust_lock(sid, expires_at)
75 | return unless expires_at < Time.now.to_f
76 |
77 | @config.dynamo_db_client.update_item(obtain_lock_opts(sid))
78 | end
79 |
80 | # @return [Hash] Options hash for obtaining the lock.
81 | def obtain_lock_opts(sid, add_opt = {})
82 | merge_all(table_opts(sid), lock_attr, add_opt)
83 | end
84 |
85 | # Sleep for given time period if the session is currently locked.
86 | def wait_to_retry(result)
87 | sleep(0.001 * @config.lock_retry_delay) if result.nil?
88 | end
89 |
90 | # Get the expiration date for the session
91 | def get_expire_date(sid)
92 | lock_date = lock_time(sid)
93 | lock_date + (0.001 * @config.lock_expiry_time) if lock_date
94 | end
95 |
96 | # Attempt to place a lock on the session.
97 | def attempt_set_lock(sid)
98 | @config.dynamo_db_client.update_item(obtain_lock_opts(sid, lock_expect))
99 | end
100 |
101 | # Lock attribute - time stamp of when session was locked.
102 | def lock_attr
103 | {
104 | attribute_updates: { 'locked_at' => updated_at },
105 | return_values: 'ALL_NEW'
106 | }
107 | end
108 |
109 | # Time in which session was updated.
110 | def updated_at
111 | { value: Time.now.to_f.to_s, action: 'PUT' }
112 | end
113 |
114 | # Attributes for locking.
115 | def add_lock_attrs(env)
116 | {
117 | add_attrs: add_attr, expect_attr: expect_lock_time(env)
118 | }
119 | end
120 |
121 | # Lock options for setting lock.
122 | def set_lock_options(env, options = {})
123 | merge_all(options, add_lock_attrs(env))
124 | end
125 |
126 | # Lock expectation.
127 | def lock_expect
128 | { expected: { 'locked_at' => { exists: false } } }
129 | end
130 |
131 | # Option to delete lock.
132 | def add_attr
133 | { 'locked_at' => { action: 'DELETE' } }
134 | end
135 |
136 | # Expectation of when lock was set.
137 | def expect_lock_time(env)
138 | {
139 | expected: {
140 | 'locked_at' => {
141 | value: env['locked_at'].to_s,
142 | exists: true
143 | }
144 | }
145 | }
146 | end
147 |
148 | # Attributes to be retrieved via client
149 | def lock_opts
150 | {
151 | attributes_to_get: ['locked_at'],
152 | consistent_read: @config.consistent_read
153 | }
154 | end
155 | end
156 | end
157 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Amazon DynamoDB Session Store
2 |
3 | This gem handles sessions for Ruby web applications using a DynamoDB backend.
4 | It is compatible with all Rack based frameworks including Rails.
5 |
6 | A DynamoDB backend provides scaling and centralized data benefits for session
7 | storage with more ease than other containers, like local servers or cookies.
8 | Once an application scales beyond a single web server, session data will need to
9 | be shared across the servers. Cookie storage places all session data on the
10 | client side, discouraging sensitive data storage. It also forces strict data size
11 | limitations. DynamoDB takes care of these concerns by allowing for a safe and
12 | scalable storage container with a much larger data size limit for session data.
13 |
14 | For more developer information, see the
15 | [Full API documentation](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/).
16 |
17 | ## Installation
18 |
19 | Add this gem to your Rack application's Gemfile:
20 |
21 | ```ruby
22 | gem 'aws-sessionstore-dynamodb', '~> 3'
23 | ```
24 |
25 | If you are using Rails, please include the
26 | [aws-sdk-rails](https://github.com/aws/aws-sdk-rails)
27 | gem for
28 | [extra functionality](https://github.com/aws/aws-sdk-rails?tab=readme-ov-file#dynamodb-session-store),
29 | including generators for the session table, ActionDispatch Session integration,
30 | a garbage collection Rake task, and more:
31 |
32 | ```ruby
33 | gem 'aws-sdk-rails', '~> 4'
34 | ```
35 |
36 | ## Configuration
37 |
38 | A number of options are available to be set in
39 | `Aws::SessionStore::DynamoDB::Configuration`, which is used throughout the
40 | application. These options can be set directly in Ruby code, in ENV variables,
41 | or in a YAML configuration file, in order of precedence.
42 |
43 | The full set of options along with defaults can be found in the
44 | [Configuration](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/Aws/SessionStore/DynamoDB/Configuration.html)
45 | documentation.
46 |
47 | ### Environment Options
48 |
49 | All Configuration options can be loaded from the environment except for
50 | `:dynamo_db_client` and `:error_handler`, which must be set in Ruby code
51 | directly if needed. The environment options must be prefixed with
52 | `AWS_DYNAMO_DB_SESSION_` and then the name of the option:
53 |
54 | AWS_DYNAMO_DB_SESSION_