Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Digest tokens and cache #344

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ffb03d9
Provide option to store the authentication token as a digest rather t…
philayres Feb 5, 2019
7e127ef
Add cache of previous authentication to allow rapid re-authentication…
philayres Feb 5, 2019
654b4b0
Add specs for cache providers and to test caching operates as expected
philayres Feb 6, 2019
365aad8
Add missing expiration time to cache writes
philayres Feb 6, 2019
31116f1
Ensure cache item invalidated if the token is set to nil
philayres Feb 6, 2019
72a41ad
Remove byebug
philayres Feb 6, 2019
4425d08
Ensure cache item invalidated if the token is set to nil
philayres Feb 6, 2019
7b0f825
Ensure acts_as_token_authenticatable only applies changes to desired …
philayres Feb 7, 2019
b69956b
Merge commit '72a41ad8112457d5a61296d51980b892a732ef72' into divide-t…
philayres Feb 7, 2019
c766fcb
Merge commit '7b0f825d74fdb06fed415317f8dc90c332abbd47' into divide-t…
philayres Feb 7, 2019
bcaf00d
Ensure acts_as_token_authenticatable only applies changes to desired …
philayres Feb 7, 2019
fb1f96d
Squash commits
philayres Feb 7, 2019
0feeb65
Upversion
philayres Feb 7, 2019
1dcf3b0
Upversion
philayres Oct 2, 2019
43af41a
Version update
philayres Oct 2, 2019
e005a56
Merge branch 'master' of https://github.com/gonzalo-bulnes/simple_tok…
philayres Oct 2, 2019
c9f0340
Merge branch 'master' into digest-tokens-and-cache
philayres Oct 2, 2019
7ca0353
Merge branch 'master' of https://github.com/gonzalo-bulnes/simple_tok…
philayres Oct 2, 2019
08d4294
Merge branch 'master' into digest-tokens-and-cache
philayres Oct 2, 2019
7074edd
Merge branch 'master' of https://github.com/gonzalo-bulnes/simple_tok…
philayres Apr 14, 2020
04db492
Upversion
philayres Apr 14, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<SomeName>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
```

Expand All @@ -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
Comment on lines +320 to +324
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the configuration uses config.sign_in_token = false then the initial sign in is performed once [...]

Isn't it the other way around? I don't see other mentions of sign_in_token in this change set, so I assume it may be a typo? It may only be matter of flipping the condition around.

Naming is hard, and that's true for config options as well, but the intent in the current implementation is to be read as "if the token is used as a sign in token" (config.sign_in_token = true) then sign in happens, which translates in a session being persisted. And if not (config.sign_in_token = false), then there is no sign in and credentials must be provided with every request. (corresponding tests)

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:
Expand Down
22 changes: 22 additions & 0 deletions lib/simple_token_authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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
42 changes: 7 additions & 35 deletions lib/simple_token_authentication/acts_as_token_authenticatable.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
89 changes: 89 additions & 0 deletions lib/simple_token_authentication/cache.rb
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions lib/simple_token_authentication/caches/dalli_provider.rb
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions lib/simple_token_authentication/caches/rails_cache_provider.rb
Original file line number Diff line number Diff line change
@@ -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
Loading