diff --git a/README.md b/README.md index 3185fb39..c89d8ff1 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,48 @@ SimpleTokenAuthentication.configure do |config| # then signing in through token authentication will be tracked as any other sign in. # # config.skip_devise_trackable = true + + # Persist authentication_token as plain text or digest + # + # If :plain (default if not specified) the authentication_token is stored + # in plain text. In this mode authentication tokens that are generated are + # guaranteed to be unique. + # + # If :digest the authentication_token is stored as a digest generated by the + # default Devise password hashing function (typically BCrypt). + + # config.persist_token_as = :digest + + # Specify a cache to allow rapid re-authentication when using a digest + # to store the authentication token + # + # By specifying a cache, the computational expense of rehashing the token on + # every request (an API for example) can be avoided, while still protecting the + # token from snooping. + # + # Both the following configurations must be provided, indicating the + # name of the SimpleTokenAuthentication::Caches::Provider class + # where SomeName is a prefix to an implemented provider class, specified lowercase + # in config.cache_provider_name + # and config.cache_connection points to an existing (or new) connection for this + # type of cache. + # The example in this case is for the dalli gem to access a memcached server, + # where an existing connection has been made and is stored in a global variable + + # config.cache_provider_name = 'dalli' + # config.cache_connection = @@dalli_connection + + # A second example is for the configured Rails cache (memory_store in this example), + # where an existing connection has been made by Rails + + # config.cache_provider_name = 'rails_cache' + # config.cache_connection = Rails.cache + + # Set an expiration time for the configured cache + # + # Each authentication cache result is sent with an expiration time. By default it is + # 15 minutes. Use 0 to indicate no expiration. + # config.cache_expiration_time = 5.minutes end ``` @@ -241,6 +283,68 @@ Usage Assuming `user` is an instance of `User`, which is _token authenticatable_: each time `user` will be saved, and `user.authentication_token.blank?` it receives a new and unique authentication token (via `Devise.friendly_token`). +### Persisting Tokens + +The configuration allows for tokens to be stored as either plain text or as a +digest generated by the default Devise password hashing function (typically BCrypt). This configuration is set with the item `config.persist_token_as`. + +#### Plain Text +If `:plain` is set, the `authentication_token` field will hold the generated +authentication token in plain text. This is the default, and was in fact the only +option before version **1.16.0**. + +In *plain text* mode tokens are checked for uniqueness when generated, and if a token +is found not to be unique it is regenerated. + +The record attribute `authentication_token` returns the stored value, which +continues to be plain text. + +#### Digest +If `:digest` is set, the `authentication_token` field will hold the digest of the +generated authentication token, along with a randomly generated salt. This has the +benefit of preventing tokens being exposed if the database or a backup is +compromised, or a DB role views the users table. + +In *digest* mode, authentication tokens can not be realistically checked for +uniqueness, so the generation of unique tokens is not guaranteed, +even if it is highly likely. + +The record attribute `authentication_token` returns the stored value, the digest. +In order to access the plain text token when it is initially +generated, instead read the attribute `plain_authentication_token`. This plain +text version is only retained in the instance after `authentication_token` is set, +therefore should be communicated to the user for future use immediately. Tokens +can not be recreated from the digest and are not persisted in the datatabase. + +#### Caching Authentications with Stored Digest Tokens +BCrypt hashing is computationally expensive by design. If the configuration uses +`config.sign_in_token = false` then the initial sign in is performed once per +session and there will be a delay only on the initial authentication. If instead +the configuration uses `config.sign_in_token = true` then the email and +authentication token will be required for every request. This will lead to a slow +response on every request, since the token must be hashed every time. +For API use this is likely to lead to poor performance. + +To avoid the penalty of rehashing on every request, `cache_provider` and +`cache_connection` options enable caching using an existing in-memory cache +(such as memcached). The approach is to cache the user id, the authentication token +(as an SHA2 digest), and the authentication status. On a +subsequent request, the cache is checked to see if the authentication has already +happened successfully. If the token is regenerated, the cached value is +invalidated. Comments in the file `lib/simple_token_authentication/cache.rb` provide +additional detail. + +The rspec example in `spec/lib/simple_token_authentication/test_caching_spec.rb` +*tests the speed of the cache versus uncached authentication* shows the speed up. +When using a BCrypt hashing cost of 13 (set by Devise.stretches), the speed up +between using the ActiveSupport MemoryStore cache against not caching is greater than +2000 times. + +It should be noted that hashing uses the same Devise defaults as for entity +passwords (including hashing cost and the Devise secret). Currently there is no +way to configure this differently for passwords and authentication tokens. + + ### Authentication Method 1: Query Params You can authenticate passing the `user_email` and `user_token` params as query params: diff --git a/lib/simple_token_authentication.rb b/lib/simple_token_authentication.rb index 3a3a367e..912447e2 100644 --- a/lib/simple_token_authentication.rb +++ b/lib/simple_token_authentication.rb @@ -48,6 +48,23 @@ def self.load_available_adapters adapters_short_names available_adapters end + # Load a cache provider + def self.load_cache_provider + + cache_short_name = SimpleTokenAuthentication.cache_provider_name + connection = SimpleTokenAuthentication.cache_connection + exp_time = SimpleTokenAuthentication.cache_expiration_time + + return nil unless cache_short_name + + cache_provider_name = "simple_token_authentication/caches/#{cache_short_name}_provider" + res = require(cache_provider_name) + cpc = cache_provider_name.camelize.constantize + cpc.connection = connection + cpc.expiration_time = exp_time + SimpleTokenAuthentication.cache_provider = cpc + end + def self.adapter_dependency_fulfilled? adapter_short_name dependency = SimpleTokenAuthentication.adapters_dependencies[adapter_short_name] @@ -59,9 +76,14 @@ def self.adapter_dependency_fulfilled? adapter_short_name end end + def self.run_post_config_setup + load_cache_provider + end + available_model_adapters = load_available_adapters SimpleTokenAuthentication.model_adapters ensure_models_can_act_as_token_authenticatables available_model_adapters available_controller_adapters = load_available_adapters SimpleTokenAuthentication.controller_adapters ensure_controllers_can_act_as_token_authentication_handlers available_controller_adapters + end diff --git a/lib/simple_token_authentication/_tmp_4df0ec6920c643bd_version.rb.rb b/lib/simple_token_authentication/_tmp_4df0ec6920c643bd_version.rb.rb new file mode 100644 index 00000000..8384743d --- /dev/null +++ b/lib/simple_token_authentication/_tmp_4df0ec6920c643bd_version.rb.rb @@ -0,0 +1,3 @@ +module SimpleTokenAuthentication + VERSION = '1.17.2'.freeze +end diff --git a/lib/simple_token_authentication/acts_as_token_authenticatable.rb b/lib/simple_token_authentication/acts_as_token_authenticatable.rb index 728cbeda..616ab8a4 100644 --- a/lib/simple_token_authentication/acts_as_token_authenticatable.rb +++ b/lib/simple_token_authentication/acts_as_token_authenticatable.rb @@ -1,47 +1,19 @@ require 'active_support/concern' -require 'simple_token_authentication/token_generator' +require 'simple_token_authentication/token_authenticatable' module SimpleTokenAuthentication module ActsAsTokenAuthenticatable - extend ::ActiveSupport::Concern - # Please see https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 - # before editing this file, the discussion is very interesting. + extend ActiveSupport::Concern - included do - private :generate_authentication_token - private :token_suitable? - private :token_generator - end - - # Set an authentication token if missing - # - # Because it is intended to be used as a filter, - # this method is -and should be kept- idempotent. - def ensure_authentication_token - if authentication_token.blank? - self.authentication_token = generate_authentication_token(token_generator) - end - end - - def generate_authentication_token(token_generator) - loop do - token = token_generator.generate_token - break token if token_suitable?(token) - end - end - - def token_suitable?(token) - self.class.where(authentication_token: token).count == 0 - end - - def token_generator - TokenGenerator.instance - end + # This module ensures that no TokenAuthenticatableHandler behaviour + # is added before the class actually `acts_as_token_authenticatable` + # otherwise we inject unnecessary methods into ORMs. + # This follows the pattern of ActsAsTokenAuthenticationHandler module ClassMethods def acts_as_token_authenticatable(options = {}) - before_save :ensure_authentication_token + include SimpleTokenAuthentication::TokenAuthenticatable end end end diff --git a/lib/simple_token_authentication/cache.rb b/lib/simple_token_authentication/cache.rb new file mode 100644 index 00000000..1972b435 --- /dev/null +++ b/lib/simple_token_authentication/cache.rb @@ -0,0 +1,89 @@ +require 'digest/sha2' +require 'simple_token_authentication/cache' + +module SimpleTokenAuthentication + module Cache + + # Cache previous authentications by a specific user record using a plain text token. + # This allows rapid re-authentication for requests that store authentication tokens + # as computationally expensive digests in the database. + + # Hash the plain text token with a strong, but computationally fast hashing function. + # This aims to avoid snooping by other users of the cache, especially since many + # caches do not require authentication by other system users. + # This new digest does not provide the full protection from attack that the persisted token + # BCrypt digest has, since it is not so computationally expensive, and therefore could be brute-forced. + # Since this hash is only intended to be stored short-term in an in-memory cache + # accessible by reasonably trusted system users, this compromise allows + # rapid validation of previous authentications, with reasonable protection + # against revealing tokens. + + # In order to reflect a session time out with cached authentications, the configuration provides + # a `cache_expiration_time` setting. This is passed to the cache every time a new authentication + # result is written. Enforcement of this time is expected to be performed by the cache. + # Cache providers can also enforce this if the specific cache does not reliably enforce + # this expiration time. + + def base_class + raise NotImplementedError + end + + # The current cache connection + def connection= c + @connection = c + end + + def connection + @connection + end + + # Time to expire previous cached authentication results + def expiration_time= e + @expiration_time = e + end + + def expiration_time + @expiration_time + end + + + # Set a new cached authentication for this record, recording the + # plain token, authentication status, and timestamp + def set_new_auth record_id, plain_token, authenticated + end + + # Get a new cached authentication for this record, recording the + # plain token, authentication status, and timestamp + def get_previous_auth record_id, plain_token + end + + # Invalidate a previous cached authentication for this record + def invalidate_auth record_id + set_new_auth record_id, nil, false + end + + # Generate a key to be used to identify the authentication for this user record + def cache_record_key record_id + {cache_record_type: 'simple_token_authentication auth record', record_id: record_id} + end + + # Generate a stored value, containing the hashed token, current authentication status, + # and a timestamp that can be used for additional TTL checking + def cache_record_value token, record_id, authenticated + {token: hash(token, record_id), authenticated: authenticated, updated_at: Time.now} + end + + # Generate a digest using the user record id, the Devise configuration pepper and the + # plain text token. + def hash token, record_id + Digest::SHA2.hexdigest("#{record_id}--#{SimpleTokenAuthentication.pepper}--#{token}") + end + + # Simple check of the cache result to validate that the result was found, + # the previous authentication was valid, and the authentication token has not changed + def check_cache_result token, record_id, res + res && res[:authenticated] == true && res[:token] == hash(token, record_id) + end + + end +end diff --git a/lib/simple_token_authentication/caches/dalli_provider.rb b/lib/simple_token_authentication/caches/dalli_provider.rb new file mode 100644 index 00000000..e7e2b8af --- /dev/null +++ b/lib/simple_token_authentication/caches/dalli_provider.rb @@ -0,0 +1,28 @@ +require 'dalli' +require 'simple_token_authentication/cache' + +module SimpleTokenAuthentication + module Caches + class DalliProvider + extend SimpleTokenAuthentication::Cache + + def self.base_class + ::Dalli + end + + # Set a new cached authentication for this record, recording the + # plain token, authentication status, and timestamp + def self.set_new_auth record_id, plain_token, authenticated + connection.set(cache_record_key(record_id), cache_record_value(plain_token, record_id, authenticated), expiration_time) + end + + # Get a new cached authentication for this record, recording the + # plain token, authentication status, and timestamp + def self.get_previous_auth record_id, plain_token + res = connection.get(cache_record_key(record_id)) + check_cache_result plain_token, record_id, res + end + + end + end +end diff --git a/lib/simple_token_authentication/caches/rails_cache_provider.rb b/lib/simple_token_authentication/caches/rails_cache_provider.rb new file mode 100644 index 00000000..65b9bd16 --- /dev/null +++ b/lib/simple_token_authentication/caches/rails_cache_provider.rb @@ -0,0 +1,28 @@ +require 'active_support/cache' +require 'simple_token_authentication/cache' + +module SimpleTokenAuthentication + module Caches + class RailsCacheProvider + extend SimpleTokenAuthentication::Cache + + def self.base_class + ::ActiveSupport::Cache::Store + end + + # Set a new cached authentication for this record, recording the + # plain token, authentication status, and timestamp + def self.set_new_auth record_id, plain_token, authenticated + connection.write(cache_record_key(record_id), cache_record_value(plain_token, record_id, authenticated), expires_in: expiration_time) + end + + # Get a new cached authentication for this record, recording the + # plain token, authentication status, and timestamp + def self.get_previous_auth record_id, plain_token + res = connection.fetch(cache_record_key(record_id)) + check_cache_result plain_token, record_id, res + end + + end + end +end diff --git a/lib/simple_token_authentication/configuration.rb b/lib/simple_token_authentication/configuration.rb index 9d1638ba..cdd26c18 100644 --- a/lib/simple_token_authentication/configuration.rb +++ b/lib/simple_token_authentication/configuration.rb @@ -9,6 +9,11 @@ module Configuration mattr_accessor :model_adapters mattr_accessor :adapters_dependencies mattr_accessor :skip_devise_trackable + mattr_accessor :persist_token_as + mattr_accessor :cache_provider_name + mattr_accessor :cache_connection + mattr_accessor :cache_provider + mattr_accessor :cache_expiration_time # Default configuration @@fallback = :devise @@ -23,10 +28,16 @@ module Configuration 'rails_api' => 'ActionController::API', 'rails_metal' => 'ActionController::Metal' } @@skip_devise_trackable = true + @@persist_token_as = :plain + @@cache_provider_name = nil + @@cache_connection = nil + @@cache_provider = nil + @@cache_expiration_time = 15.minutes # Allow the default configuration to be overwritten from initializers def configure yield self if block_given? + run_post_config_setup end def parse_options(options) @@ -46,5 +57,21 @@ def parse_options(options) options.reject! { |k,v| k == :fallback_to_devise } options end + + def persist_token_as_plain? + SimpleTokenAuthentication.persist_token_as == :plain + end + + def persist_token_as_digest? + SimpleTokenAuthentication.persist_token_as == :digest + end + + def pepper + Devise.pepper + end + + def stretches + Devise.stretches + end end end diff --git a/lib/simple_token_authentication/token_authenticatable.rb b/lib/simple_token_authentication/token_authenticatable.rb new file mode 100644 index 00000000..c57dcc34 --- /dev/null +++ b/lib/simple_token_authentication/token_authenticatable.rb @@ -0,0 +1,86 @@ +require 'active_support/concern' +require 'simple_token_authentication/token_generator' + +module SimpleTokenAuthentication + module TokenAuthenticatable + extend ::ActiveSupport::Concern + + # Please see https://gist.github.com/josevalim/fb706b1e933ef01e4fb6 + # before editing this file, the discussion is very interesting. + + included do + private :generate_authentication_token + private :token_suitable? + private :token_generator + private :invalidate_cached_auth + + before_save :ensure_authentication_token + + attr_accessor :plain_authentication_token, :persisted_authentication_token + end + + def authentication_token= token + + self.plain_authentication_token = token + + if token.nil? + self.persisted_authentication_token = nil + elsif SimpleTokenAuthentication.persist_token_as_plain? + # Persist the plain token + self.persisted_authentication_token = token + elsif SimpleTokenAuthentication.persist_token_as_digest? + # Persist the digest of the token + self.persisted_authentication_token = Devise::Encryptor.digest(SimpleTokenAuthentication, token) + end + + invalidate_cached_auth + + # Check for existence of an write_attribute method, to allow specs to operate without a true persistence layer + if defined?(write_attribute) + write_attribute(:authentication_token, self.persisted_authentication_token) + end + end + + def authentication_token + if defined?(read_attribute) + read_attribute :authentication_token + else + persisted_authentication_token + end + end + + # Set an authentication token if missing + # + # Because it is intended to be used as a filter, + # this method is -and should be kept- idempotent. + def ensure_authentication_token + if authentication_token.blank? + self.authentication_token = generate_authentication_token(token_generator) + end + end + + def generate_authentication_token(token_generator) + loop do + token = token_generator.generate_token + break token if token_suitable?(token) + end + end + + def token_suitable?(token) + # Alway true if digest is persisted, since we can't check for duplicates + return true if SimpleTokenAuthentication.persist_token_as_digest? + self.class.where(authentication_token: token).count == 0 + end + + def token_generator + TokenGenerator.instance + end + + # Invalidate an existing cache item + def invalidate_cached_auth + cache = SimpleTokenAuthentication.cache_provider + cache.invalidate_auth(self.id) if cache + end + + end +end diff --git a/lib/simple_token_authentication/token_authentication_handler.rb b/lib/simple_token_authentication/token_authentication_handler.rb index 43df39e2..c2858a95 100644 --- a/lib/simple_token_authentication/token_authentication_handler.rb +++ b/lib/simple_token_authentication/token_authentication_handler.rb @@ -25,6 +25,8 @@ module TokenAuthenticationHandler private :sign_in_handler private :find_record_from_identifier private :integrate_with_devise_case_insensitive_keys + private :cached_auth? + private :cache_new_auth end # This method is a hook and is meant to be overridden. @@ -38,9 +40,13 @@ def after_successful_token_authentication def authenticate_entity_from_token!(entity) record = find_record_from_identifier(entity) - if token_correct?(record, entity, token_comparator) + if cached_auth?(record, entity) || token_correct?(record, entity, token_comparator) + cache_new_auth(record, entity, true) perform_sign_in!(record, sign_in_handler) after_successful_token_authentication + else + cache_new_auth(record, entity, false) + false end end @@ -71,6 +77,30 @@ def find_record_from_identifier(entity) identifier_param_value && entity.model.find_for_authentication(entity.identifier => identifier_param_value) end + # If a cache has been specified + # get the previous authentication result for this record with this token + # Only if found and the previous result was 'authenticated' return true + # Otherwise return false + def cached_auth?(record, entity) + return false unless record && entity + + cache = SimpleTokenAuthentication.cache_provider + if cache + res = cache.get_previous_auth(record.id, entity.get_token_from_params_or_headers(self)) + return res + end + false + end + + # Store a new auth result to cache + def cache_new_auth(record, entity, success) + cache = SimpleTokenAuthentication.cache_provider + if cache + res = cache.set_new_auth(record.id, entity.get_token_from_params_or_headers(self), success) + end + end + + # Private: Take benefit from Devise case-insensitive keys # # See https://github.com/plataformatec/devise/blob/v3.4.1/lib/generators/templates/devise.rb#L45-L48 diff --git a/lib/simple_token_authentication/token_comparator.rb b/lib/simple_token_authentication/token_comparator.rb index c6a39c47..f419db1b 100644 --- a/lib/simple_token_authentication/token_comparator.rb +++ b/lib/simple_token_authentication/token_comparator.rb @@ -15,7 +15,12 @@ def compare(a, b) # while mitigating timing attacks. # See http://rubydoc.info/github/plataformatec/\ # devise/master/Devise#secure_compare-class_method - Devise.secure_compare(a, b) + + if SimpleTokenAuthentication.persist_token_as_plain? + Devise.secure_compare(a, b) + elsif SimpleTokenAuthentication.persist_token_as_digest? + Devise::Encryptor.compare(SimpleTokenAuthentication, a, b) + end end end end diff --git a/lib/simple_token_authentication/version.rb b/lib/simple_token_authentication/version.rb index 4d42aa22..8384743d 100644 --- a/lib/simple_token_authentication/version.rb +++ b/lib/simple_token_authentication/version.rb @@ -1,3 +1,3 @@ module SimpleTokenAuthentication - VERSION = "1.17.0".freeze + VERSION = '1.17.2'.freeze end diff --git a/simple_token_authentication.gemspec b/simple_token_authentication.gemspec index 8ee56b24..f6208fa1 100644 --- a/simple_token_authentication.gemspec +++ b/simple_token_authentication.gemspec @@ -25,4 +25,7 @@ Gem::Specification.new do |s| s.add_development_dependency "activerecord", ">= 3.2.6", "< 7" s.add_development_dependency 'mongoid', ">= 3.1.0", "< 8" s.add_development_dependency "appraisal", "~> 2.0" + + s.add_development_dependency 'dalli' + s.add_development_dependency 'activesupport' end diff --git a/spec/configuration/header_names_option_spec.rb b/spec/configuration/header_names_option_spec.rb index 15502bd7..a04a3889 100644 --- a/spec/configuration/header_names_option_spec.rb +++ b/spec/configuration/header_names_option_spec.rb @@ -8,6 +8,12 @@ def skip_devise_case_insensitive_keys_integration!(controller) describe 'Simple Token Authentication' do + before(:all) do + SimpleTokenAuthentication.persist_token_as = :plain + + + end + describe ':header_names option', header_names_option: true do describe 'determines which header fields are looked at for authentication credentials', before_filter: true, before_action: true do diff --git a/spec/lib/simple_token_authentication/acts_as_token_authenticatable_spec.rb b/spec/lib/simple_token_authentication/acts_as_token_authenticatable_spec.rb index 3579f49b..e3cfe753 100644 --- a/spec/lib/simple_token_authentication/acts_as_token_authenticatable_spec.rb +++ b/spec/lib/simple_token_authentication/acts_as_token_authenticatable_spec.rb @@ -57,6 +57,8 @@ def generate_token it 'responds to :ensure_authentication_token', protected: true do @subjects.map!{ |subject| subject.new } @subjects.each do |subject| + allow(subject.class).to receive(:before_save) + subject.class.acts_as_token_authenticatable expect(subject).to respond_to :ensure_authentication_token end end @@ -68,19 +70,10 @@ def generate_token @subjects.each do |k| k.class_eval do - def initialize(args={}) @authentication_token = args[:authentication_token] end - def authentication_token=(value) - @authentication_token = value - end - - def authentication_token - @authentication_token - end - # the 'ExampleTok3n' is already in use def token_suitable?(token) not TOKENS_IN_USE.include? token @@ -92,12 +85,18 @@ def token_generator token_generator end end + k.send(:include, SimpleTokenAuthentication::ActsAsTokenAuthenticatable) end @subjects.map!{ |subject| subject.new } end - it 'ensures its authentication token is unique', public: true do + it 'ensures its authentication token is unique if storing tokens as plaintext', public: true do + SimpleTokenAuthentication.persist_token_as = :plain + @subjects.each do |subject| + allow(subject.class).to receive(:before_save) + subject.class.acts_as_token_authenticatable + subject.ensure_authentication_token expect(subject.authentication_token).not_to eq 'ExampleTok3n' @@ -105,6 +104,54 @@ def token_generator expect(subject.authentication_token).to eq 'Dist1nCt-Tok3N' end end + + + it "prevents the authentication token being stored in plain text if digest has been configured", public: true do + # set the config to use plaintext persisted tokens + SimpleTokenAuthentication.persist_token_as = :digest + + @subjects.each do |subject| + allow(subject.class).to receive(:before_save) + subject.class.acts_as_token_authenticatable + + subject.ensure_authentication_token + expect(subject.plain_authentication_token).to eq 'Dist1nCt-Tok3N' + expect(subject.persisted_authentication_token).not_to be nil + expect(subject.persisted_authentication_token).not_to eq 'Dist1nCt-Tok3N' + end + end + + it "ensures the stored digest is a true digest" do + SimpleTokenAuthentication.persist_token_as = :digest + + @subjects.each do |subject| + allow(subject.class).to receive(:before_save) + subject.class.acts_as_token_authenticatable + + subject.ensure_authentication_token + hashed_token = subject.persisted_authentication_token + plain_token = subject.plain_authentication_token + comp = Devise::Encryptor.compare(SimpleTokenAuthentication, hashed_token, plain_token) + expect(comp).to be true + end + end + + it "ensures a stored digest can be compared" do + + SimpleTokenAuthentication.persist_token_as = :digest + token_comparator = SimpleTokenAuthentication::TokenComparator.instance + @subjects.each do |subject| + allow(subject.class).to receive(:before_save) + subject.class.acts_as_token_authenticatable + + subject.ensure_authentication_token + hashed_token = subject.persisted_authentication_token + plain_token = subject.plain_authentication_token + comp = token_comparator.compare(hashed_token, plain_token) + expect(comp).to be true + end + end + end end end diff --git a/spec/lib/simple_token_authentication/cache_spec.rb b/spec/lib/simple_token_authentication/cache_spec.rb new file mode 100644 index 00000000..7d2828cf --- /dev/null +++ b/spec/lib/simple_token_authentication/cache_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe 'Any class which extends SimpleTokenAuthentication::Cache' do + + after(:each) do + SimpleTokenAuthentication.send(:remove_const, :SomeClass) + end + + before(:each) do + @subject = define_dummy_class_which_extends(SimpleTokenAuthentication::Cache) + end + + describe '.base_class' do + + it 'raises an error if not overwritten', public: true do + expect{ @subject.base_class }.to raise_error NotImplementedError + end + end +end diff --git a/spec/lib/simple_token_authentication/caches/dalli_provider_spec.rb b/spec/lib/simple_token_authentication/caches/dalli_provider_spec.rb new file mode 100644 index 00000000..849b106c --- /dev/null +++ b/spec/lib/simple_token_authentication/caches/dalli_provider_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' +require 'simple_token_authentication/caches/dalli_provider' + +describe 'SimpleTokenAuthentication::Caches::DalliProvider' do + + before(:each) do + + stub_const('Dalli', double()) + + @subject = SimpleTokenAuthentication::Caches::DalliProvider + end + + it_behaves_like 'a cache' + + describe '.base_class' do + + it 'is ::Dalli', private: true do + expect(@subject.base_class).to eq ::Dalli + end + end +end diff --git a/spec/lib/simple_token_authentication/caches/rails_cache_provider_spec.rb b/spec/lib/simple_token_authentication/caches/rails_cache_provider_spec.rb new file mode 100644 index 00000000..b1ee3cc3 --- /dev/null +++ b/spec/lib/simple_token_authentication/caches/rails_cache_provider_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' +require 'simple_token_authentication/caches/rails_cache_provider' + +describe 'SimpleTokenAuthentication::Caches::RailsCacheProvider' do + + before(:each) do + + stub_const('ActiveSupport::Cache::Store', double()) + + @subject = SimpleTokenAuthentication::Caches::RailsCacheProvider + end + + it_behaves_like 'a cache' + + describe '.base_class' do + + it 'is ::ActiveSupport::Cache::Store', private: true do + expect(@subject.base_class).to eq ::ActiveSupport::Cache::Store + end + end + +end diff --git a/spec/lib/simple_token_authentication/test_caching_spec.rb b/spec/lib/simple_token_authentication/test_caching_spec.rb new file mode 100644 index 00000000..1fff5e53 --- /dev/null +++ b/spec/lib/simple_token_authentication/test_caching_spec.rb @@ -0,0 +1,230 @@ +require 'spec_helper' +require 'simple_token_authentication/caches/rails_cache_provider' + + +describe 'SimpleTokenAuthentication::Caches::RailsCacheProvider' do + + def use_auth_token token + allow(@entity).to receive(:get_token_from_params_or_headers).and_return(token) + end + + def authentication + @controller.send :authenticate_entity_from_token!, @entity + end + + before(:all) do + Rails.cache = ActiveSupport::Cache.lookup_store(:memory_store) + SimpleTokenAuthentication.cache_provider_name = 'rails_cache' + SimpleTokenAuthentication.cache_connection = Rails.cache + SimpleTokenAuthentication.send(:load_cache_provider) + end + + before(:each) do + raise "Cache not configured" unless cache_provider == SimpleTokenAuthentication::Caches::RailsCacheProvider + Rails.cache.clear + end + + let(:cache_provider) { SimpleTokenAuthentication.cache_provider } + + describe 'test memory_store operation' do + it 'uses memory_store to test caching' do + + record_id = 1 + plain_token = 'TestToken1' + res = cache_provider.get_previous_auth record_id, plain_token + expect(res).to be_falsey + + authenticated = true + cache_provider.set_new_auth record_id, plain_token, authenticated + + res = cache_provider.get_previous_auth record_id, plain_token + expect(res).to eq true + + end + end + + describe 'test cache works with sign_in' do + + let(:token_authentication_handler) { SimpleTokenAuthentication::TokenAuthenticationHandler } + let(:id) {2} + let(:email) {'jondelario@test.com'} + let(:bad_token) {'TestToken2'} + let(:good_token) {'hello123!'} + let(:new_good_token) {'Another token to test'} + + + + before :each do + + id = 2 + email = 'jondelario@test.com' + bad_token = 'TestToken2' + good_token = 'hello123!' + + SimpleTokenAuthentication.persist_token_as = :digest + + @subject = Class.new do + def self.before_save _ + end + include SimpleTokenAuthentication::ActsAsTokenAuthenticatable + acts_as_token_authenticatable + + attr_accessor :email, :id + + def initialize(opt={}) + self.id = opt[:id] + self.email = opt[:email] + self.authentication_token = opt[:authentication_token] + end + + end + + @controller_class = Class.new do + include SimpleTokenAuthentication::TokenAuthenticationHandler + + def after_successful_token_authentication + true + end + + end + + @record = @subject.new(email: email, authentication_token: good_token, id: id) + @controller = @controller_class.new + @entity = double(id: id) + + allow(@entity).to receive(:get_identifier_from_params_or_headers).and_return(email) + use_auth_token(bad_token) + allow(@controller).to receive(:find_record_from_identifier).and_return(@record) + allow(@controller).to receive(:perform_sign_in!).and_return(true) + + end + + + it "correctly handles a series of authentications" do + + expect(authentication).to be false + + use_auth_token(good_token) + + expect(authentication).to be true + + expect(authentication).to be true + + use_auth_token(bad_token) + + expect(authentication).to be false + + end + + it "handles a cache crash" do + + use_auth_token(good_token) + + expect(authentication).to be true + + Rails.cache.clear + + expect(authentication).to be true + + end + + it "gets the results from cache, not locally" do + + use_auth_token(good_token) + + expect(authentication).to be true + + # Force the token in the User class to be returned as a bad token, without + # triggering the generation of a new digest. The cache will not be cleared, + # and so the cache result will be good. If the cache result was not returned + # for some reason, the authentication would fail (which we'll confirm in a moment) + @record.send(:persisted_authentication_token=, nil) + + expect(authentication).to be true + + # Clear the cache, demonstrating that the previous test was valid, since it should + # now fail as the cached value is no longer present + Rails.cache.clear + + expect(authentication).to be false + + end + + it "clears the cached authentication when the token is changed" do + + use_auth_token(good_token) + + expect(authentication).to be true + + # Update the token, which invalidates the authenticated cache item + @record.authentication_token = new_good_token + + expect(authentication).to be false + + # Use the new token + use_auth_token(new_good_token) + expect(authentication).to be true + + end + + it "tests the speed of the cache versus uncached authentication" do + SimpleTokenAuthentication.persist_token_as = :digest + + Devise.stretches = 13 + @record.authentication_token = new_good_token + + use_auth_token(new_good_token) + + puts "Testing Cache Speedup" + + total_cache_time = 0 + total_initial_time = 0 + + 10.times do + Rails.cache.clear + t1 = Time.now + expect(authentication).to be true + t2 = Time.now + initial_time = (t2 - t1).to_f + total_initial_time += initial_time + + t1 = Time.now + expect(authentication).to be true + t2 = Time.now + cache_time = (t2 - t1).to_f + total_cache_time += cache_time + + end + + speedup = (total_initial_time / total_cache_time) + puts "Speedup = #{speedup.to_i} times (with hashing cost #{Devise.stretches})" + expect(speedup).to be > 100 + + end + + it "does not break plain text persisted tokens" do + SimpleTokenAuthentication.persist_token_as = :plain + + @record.authentication_token = new_good_token + use_auth_token(new_good_token) + expect(authentication).to be true + + use_auth_token(bad_token) + expect(authentication).to be false + + use_auth_token(bad_token) + expect(authentication).to be false + + use_auth_token(new_good_token) + expect(authentication).to be true + + end + + end + + after :all do + SimpleTokenAuthentication.persist_token_as = :plain + SimpleTokenAuthentication.cache_provider_name = nil + SimpleTokenAuthentication.cache_provider = nil + end +end diff --git a/spec/lib/simple_token_authentication/token_comparator_spec.rb b/spec/lib/simple_token_authentication/token_comparator_spec.rb index 59d9c2ba..685d268b 100644 --- a/spec/lib/simple_token_authentication/token_comparator_spec.rb +++ b/spec/lib/simple_token_authentication/token_comparator_spec.rb @@ -6,7 +6,28 @@ it_behaves_like 'a token comparator' + it 'delegates token comparison to Devise::Encryptor.compare', private: true do + + # set the config to use hashed persisted tokens + SimpleTokenAuthentication.persist_token_as = :digest + + encryptor = double() + allow(encryptor).to receive(:compare).and_return('Devise::Encryptor.compare response.') + stub_const('Devise::Encryptor', encryptor) + + # delegating consists in sending the message + expect(Devise::Encryptor).to receive(:compare) + response = token_comparator.compare('A_raNd0MtoKeN', 'ano4heR-Tok3n') + + # and returning the response + expect(response).to eq 'Devise::Encryptor.compare response.' + end + it 'delegates token comparison to Devise.secure_compare', private: true do + + # set the config to use plaintext persisted tokens + SimpleTokenAuthentication.persist_token_as = :plain + devise = double() allow(devise).to receive(:secure_compare).and_return('Devise.secure_compare response.') stub_const('Devise', devise) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5518ab57..4f949b26 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,7 +4,6 @@ require 'action_controller' require 'active_record' require 'active_support' - require 'simple_token_authentication' Dir["./spec/support/**/*.rb"].sort.each { |f| require f; puts f } diff --git a/spec/support/spec_for_cache.rb b/spec/support/spec_for_cache.rb new file mode 100644 index 00000000..6326eedb --- /dev/null +++ b/spec/support/spec_for_cache.rb @@ -0,0 +1,10 @@ +RSpec.shared_examples 'a cache' do + + it 'responds to :base_class', public: true do + expect(@subject).to respond_to :base_class + end + + it 'defines :base_class', public: true do + expect { @subject.base_class }.not_to raise_error + end +end