Skip to content

Commit 0aab2c6

Browse files
authored
Merge pull request #29 from RadiusNetworks/switch-to-in-memory-cache
Switch to in memory cache and obfuscate tokens
2 parents c9d6ca8 + ae642bb commit 0aab2c6

File tree

6 files changed

+166
-35
lines changed

6 files changed

+166
-35
lines changed

.travis.yml

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,21 @@ language: ruby
22
bundler_args: --binstubs --jobs=3 --retry=3
33
cache: bundler
44
sudo: false
5+
before_install:
6+
- gem update --system
7+
- gem install bundler
58
script: bin/rake
69
rvm:
7-
- 2.3.3
8-
- 2.4.0
910
- 2.5.0
1011
env:
12+
- RAILS_VERSION='~> 5.2.0'
1113
- RAILS_VERSION='~> 5.1.0'
12-
- RAILS_VERSION='~> 5.0.0'
13-
- RAILS_VERSION='~> 4.2.8'
14-
- RAILS_VERSION='4-2-stable'
15-
- RAILS_VERSION='5-0-stable'
1614
- RAILS_VERSION='5-1-stable'
15+
- RAILS_VERSION='5-2-stable'
1716
matrix:
1817
include:
1918
- rvm: 2.2.3
20-
env: RAILS_VERSION="4.2.5"
21-
- rvm: 2.3.3
22-
env: RAILS_VERSION='4.2.7.1'
19+
env: RAILS_VERSION='~> 5.1.0'
20+
- rvm: 2.4.4
21+
env: RAILS_VERSION='~> 4.2.10'
2322
fast_finish: true

lib/kracken/authenticator.rb

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,30 @@ module Kracken
22
class Authenticator
33
attr_reader :auth_hash
44

5+
# @private
6+
def self.cache
7+
@cache
8+
end
9+
@cache = ActiveSupport::Cache.lookup_store(
10+
:memory_store,
11+
size: 5.megabytes,
12+
expires_in: 1.hour,
13+
race_condition_ttl: 1.second,
14+
)
15+
516
## Factory Methods
617

718
# Login the user with their credentails. Used for proxying the
819
# authentication to the auth server, normally from a mobile app
920
def self.user_with_credentials(email, password)
1021
auth = Kracken::CredentialAuthenticator.new.fetch(email, password)
11-
self.new(auth.body).to_app_user
22+
new(auth).to_app_user
1223
end
1324

1425
# Login the user with an auth token. Used for API authentication for the
1526
# public APIs
1627
def self.user_with_token(token)
17-
auth = Kracken::TokenAuthenticator.new.fetch(token)
28+
auth = new(Kracken::TokenAuthenticator.new.fetch(token))
1829

1930
# Don't want stale user models being pulled from the cache. So only
2031
# cache the `user_id`.
@@ -23,15 +34,30 @@ def self.user_with_token(token)
2334
# for the user, set it to nil, fetch from cache and only query if there
2435
# was a cache-hit (thus user is still nil).
2536
user = nil
26-
user_id = Rails.cache.fetch("auth/#{token}/#{auth.etag}") {
27-
user = self.new(auth.body).to_app_user
37+
user_id = Authenticator.cache.fetch(auth) {
38+
user = auth.to_app_user
2839
user.id
2940
}
3041
user ||= Kracken.config.user_class.find(user_id)
3142
end
3243

3344
def initialize(response)
34-
@auth_hash = create_auth_hash(response)
45+
@auth_hash = create_auth_hash(response.body)
46+
@etag = if response.respond_to?(:etag)
47+
response.etag
48+
else
49+
# https://github.com/rails/rails/blob/v5.2.0/actionpack/lib/action_dispatch/http/cache.rb#L136
50+
# https://github.com/rails/rails/blob/v5.2.0/activesupport/lib/active_support/digest.rb
51+
# https://github.com/rails/rails/blob/v4.2.10/actionpack/lib/action_dispatch/http/cache.rb#L87
52+
::Digest::MD5.hexdigest(response.body.to_json)
53+
end
54+
@etag.freeze
55+
end
56+
57+
attr_reader :etag
58+
59+
def cache_key
60+
"#{@auth_hash.provider || :unknown}/#{@auth_hash.uid}-#{etag}"
3561
end
3662

3763
# Convert this Factory to a User object per the host app.
@@ -40,7 +66,7 @@ def to_app_user
4066
Kracken.config.user_class.find_or_create_from_auth_hash(auth_hash)
4167
end
4268

43-
private
69+
private
4470

4571
def create_auth_hash(response_hash)
4672
Hashie::Mash.new({

lib/kracken/config.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
module Kracken
22
class Config
3-
attr_accessor :app_id, :app_secret, :user_class
3+
attr_accessor :app_id, :app_secret
44
attr_writer :provider_url
55

6+
# @deprecated the associated reader returns static `::User`
7+
attr_writer :user_class
8+
69
def initialize
710
@user_class = nil
811
end

lib/kracken/controllers/token_authenticatable.rb

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
module Kracken
22
module Controllers
33
module TokenAuthenticatable
4+
# @private
5+
def self.cache
6+
@cache
7+
end
8+
49
def self.included(base)
510
base.define_singleton_method(:realm) do |realm = nil|
611
realm ||= (superclass.try(:realm) || 'Application')
@@ -31,38 +36,75 @@ def request_http_token_authentication(realm = 'Application')
3136

3237
module_function
3338

34-
TOKEN_AUTH_CACHE_PREFIX = "auth/token/"
39+
# @private
40+
def auth_cache_key(token)
41+
# This key must **ALWAYS** be generated and stored either in-memory or
42+
# "securely" on the server. Treat this like the Rails / Devise secret
43+
# key.
44+
#
45+
# If in the future we move back to an external cache (say Redis) for
46+
# the cached auth token storage this must still remain **ONLY** on the
47+
# server.
48+
key = TokenAuthenticatable.cache.fetch("KEY", AUTH_KEY_OPTS) {
49+
# Clear all existing data generated by previous key
50+
clear_auth_cache
51+
# HMAC-SHA256 takes a maximum key size of 64-bytes
52+
# See https://crypto.stackexchange.com/questions/34864/key-size-for-hmac-sha256/34866#34866
53+
SecureRandom.random_bytes(64)
54+
}
55+
OpenSSL::HMAC.digest("SHA256", key, token)
56+
end
3557

3658
def cache_valid_auth(token, force: false, &generate_cache)
37-
cache_key = TOKEN_AUTH_CACHE_PREFIX + token
38-
val = Rails.cache.read(cache_key) unless force
39-
val ||= store_valid_auth(cache_key, &generate_cache)
59+
cache_key = auth_cache_key(token)
60+
val, nonce, hmac = TokenAuthenticatable.cache.read(cache_key) unless force
61+
val = nil unless hmac == OpenSSL::HMAC.digest("SHA256", "#{nonce}#{token}", val.to_s)
62+
val ||= store_valid_auth(token, cache_key, &generate_cache)
4063
shallow_freeze(val)
4164
end
4265

4366
def clear_auth_cache
44-
Rails.cache.delete_matched TOKEN_AUTH_CACHE_PREFIX + "*"
67+
TokenAuthenticatable.cache.clear
4568
end
4669

4770
def shallow_freeze(val)
48-
# `nil` is frozen in Ruby 2.2 but not in Ruby 2.1
49-
return val if val.frozen? || val.nil?
71+
return val if val.frozen?
5072
val.each { |_k, v| v.freeze }.freeze
5173
end
5274

53-
def store_valid_auth(cache_key)
54-
val = yield
55-
Rails.cache.write(cache_key, val, CACHE_TTL_OPTS) if val
75+
def store_valid_auth(token, cache_key = auth_cache_key(token))
76+
return unless (val = yield(token))
77+
78+
# HMAC-SHA256 takes a maximum of 64-bytes for the key. When data is
79+
# longer it is hashed, truncating to 32-bytes. We add the nonce here to
80+
# force us over that 64-byte limit, thus hashing the result. This is to
81+
# ensure at least 32-bytes of entropy from the token. This HMAC is
82+
# mearly meant as a hash collision guard, not as added security.
83+
nonce = SecureRandom.random_bytes(64)
84+
hmac = OpenSSL::HMAC.digest("SHA256", "#{nonce}#{token}", val.to_s)
85+
TokenAuthenticatable.cache.write cache_key,
86+
[val, nonce, hmac].freeze,
87+
CACHE_TTL_OPTS
5688
val
5789
end
5890

5991
private
6092

93+
AUTH_KEY_OPTS = {
94+
expires_in: 5.minutes, # Rotate key relatively frequently
95+
race_condition_ttl: 1.second,
96+
}.freeze
97+
6198
CACHE_TTL_OPTS = {
6299
expires_in: ENV.fetch("KRACKEN_TOKEN_TTL", 1.minute).to_i,
63100
race_condition_ttl: 1.second,
64101
}.freeze
65102

103+
@cache = ActiveSupport::Cache.lookup_store(
104+
:memory_store,
105+
CACHE_TTL_OPTS.merge(size: 5.megabytes),
106+
)
107+
66108
# `authenticate_or_request_with_http_token` is a nice Rails helper:
67109
# http://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token/ControllerMethods.html#method-i-authenticate_or_request_with_http_token
68110
def authenticate_user_with_token!

spec/kracken/controllers/token_authenticatable_spec.rb

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,16 @@ def authenticate_or_request_with_http_token(realm = nil)
6565
}
6666
}
6767
let(:cached_token) { "any token" }
68-
let(:cache_key) { "auth/token/any token" }
68+
let(:cache_key) { "any token" }
6969

7070
before do
7171
a_controller.request.env = {
7272
'HTTP_AUTHORIZATION' => "Token token=\"#{cached_token}\""
7373
}
7474

75-
Rails.cache.write cache_key, auth_info
75+
Controllers::TokenAuthenticatable.store_valid_auth cached_token do
76+
auth_info
77+
end
7678
stub_const "Kracken::Authenticator", spy("Kracken::Authenticator")
7779
end
7880

@@ -163,7 +165,9 @@ def authenticate_or_request_with_http_token(realm = nil)
163165
expect {
164166
a_controller.authenticate_user_with_token!
165167
}.not_to change {
166-
Rails.cache.exist?("auth/token/#{invalid_token}")
168+
Controllers::TokenAuthenticatable.cache.exist?(
169+
Controllers::TokenAuthenticatable.auth_cache_key(invalid_token)
170+
)
167171
}.from false
168172
end
169173
end
@@ -218,18 +222,28 @@ def authenticate_or_request_with_http_token(realm = nil)
218222
expect(a_controller.current_team_ids).to be_frozen
219223
end
220224

221-
it "sets the auth info as the cache value" do
225+
it "sets the auth info, along with a nonce, and HMAC as the cache value" do
222226
expect {
223227
a_controller.authenticate_user_with_token!
224-
}.to change { Rails.cache.read("auth/token/any token") }.from(nil).to(
225-
id: :any_id,
226-
team_ids: [:some, :team, :ids],
228+
}.to change {
229+
Controllers::TokenAuthenticatable.cache.read(
230+
Controllers::TokenAuthenticatable.auth_cache_key("any token")
231+
)
232+
}.from(nil).to(
233+
[
234+
{
235+
id: :any_id,
236+
team_ids: [:some, :team, :ids],
237+
},
238+
be_a(String).and(satisfy { |s| s.size == 64 }),
239+
be_a(String).and(satisfy { |s| s.size == 32 }),
240+
]
227241
)
228242
end
229243

230244
it "sets the cache expiration to one minute by default" do
231-
expect(Rails.cache).to receive(:write).with(
232-
"auth/token/any token",
245+
expect(Controllers::TokenAuthenticatable.cache).to receive(:write).with(
246+
Controllers::TokenAuthenticatable.auth_cache_key("any token"),
233247
anything,
234248
include(expires_in: 1.minute),
235249
)
@@ -241,6 +255,51 @@ def authenticate_or_request_with_http_token(realm = nil)
241255
a_controller.authenticate_user_with_token!
242256
expect(a_controller.current_user).to be a_user
243257
end
258+
259+
it "treats an HMAC verification failure as a miss" do
260+
initial_salt = "salt" * 16
261+
initial_hmac = "hmac" * 8
262+
cache_key = Controllers::TokenAuthenticatable.auth_cache_key(
263+
"any token"
264+
)
265+
Controllers::TokenAuthenticatable.cache.write(
266+
cache_key,
267+
[
268+
{
269+
id: :any_id,
270+
team_ids: [:some, :team, :ids],
271+
},
272+
initial_salt,
273+
initial_hmac,
274+
],
275+
)
276+
277+
expect {
278+
a_controller.authenticate_user_with_token!
279+
}.to change {
280+
Controllers::TokenAuthenticatable.cache.read(cache_key)
281+
}.from(
282+
[
283+
{
284+
id: :any_id,
285+
team_ids: [:some, :team, :ids],
286+
},
287+
initial_salt,
288+
initial_hmac,
289+
],
290+
).to(
291+
[
292+
{
293+
id: :any_id,
294+
team_ids: [:some, :team, :ids],
295+
},
296+
be_a(String).and(satisfy { |s| s.size == 64 && s != initial_salt}),
297+
be_a(String).and(satisfy { |s| s.size == 32 && s != initial_hmac}),
298+
]
299+
)
300+
expect(Authenticator).to have_received(:user_with_token)
301+
.with(valid_token)
302+
end
244303
end
245304
end
246305
end

spec/support/using_cache.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@
1010

1111
before do
1212
Rails.cache.clear
13+
Kracken::Controllers::TokenAuthenticatable.cache.clear
14+
Kracken::Authenticator.cache.clear
1315
end
1416
end

0 commit comments

Comments
 (0)