Skip to content

Commit 817aaf5

Browse files
committed
Merge pull request #12 from RadiusNetworks/cache-auth-tokens
Cache auth tokens
2 parents cbaf412 + 78d84c7 commit 817aaf5

File tree

6 files changed

+316
-13
lines changed

6 files changed

+316
-13
lines changed

lib/kracken/controllers/token_authenticatable.rb

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,57 @@ def self.included(base)
1111
before_action :authenticate_user_with_token!
1212
helper_method :current_user
1313
end
14-
15-
1614
end
1715

18-
attr_reader :current_user
19-
2016
# NOTE: Monkey-patch until this is merged into the gem
2117
def request_http_token_authentication(realm = 'Application')
2218
headers["WWW-Authenticate"] = %(Token realm="#{realm.gsub(/"/, "")}")
2319
raise TokenUnauthorized, "Invalid Credentials"
2420
end
2521

26-
private
22+
private
23+
24+
CACHE_TTL_OPTS = {
25+
expires_in: ENV.fetch("KRACKEN_TOKEN_TTL", 1.minute).to_i,
26+
race_condition_ttl: 1.second,
27+
}.freeze
2728

2829
# `authenticate_or_request_with_http_token` is a nice Rails helper:
2930
# http://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token/ControllerMethods.html#method-i-authenticate_or_request_with_http_token
3031
def authenticate_user_with_token!
31-
unless current_user
32-
munge_header_auth_token!
32+
munge_header_auth_token!
3333

34-
authenticate_or_request_with_http_token(realm) { |token, _options|
35-
# Attempt to reduce namespace conflicts with controllers which may access
36-
# an team instance for display.
37-
@current_user = Authenticator.user_with_token(token)
34+
authenticate_or_request_with_http_token(realm) { |token, _options|
35+
# Attempt to reduce ivar namespace conflicts with controllers
36+
@_auth_info = cache_valid_auth(token) {
37+
if @current_user = Authenticator.user_with_token(token)
38+
{ id: @current_user.id, team_ids: @current_user.team_ids }
39+
end
3840
}
39-
end
41+
}
42+
end
43+
44+
def cache_valid_auth(token, &generate_cache)
45+
cache_key = "auth/token/#{token}"
46+
val = Rails.cache.read(cache_key)
47+
val ||= store_valid_auth(cache_key, &generate_cache)
48+
val.transform_values!(&:freeze).freeze if val
49+
end
50+
51+
def current_auth_info
52+
@_auth_info ||= {}
53+
end
54+
55+
def current_team_ids
56+
current_auth_info[:team_ids]
57+
end
58+
59+
def current_user
60+
@current_user ||= Kracken.config.user_class.find(current_auth_info[:id])
61+
end
62+
63+
def current_user_id
64+
current_auth_info[:id]
4065
end
4166

4267
# Make it **explicit** that we are munging the `token` param with the
@@ -55,6 +80,12 @@ def munge_header_auth_token!
5580
def realm
5681
self.class.realm
5782
end
83+
84+
def store_valid_auth(cache_key)
85+
val = yield
86+
Rails.cache.write(cache_key, val, CACHE_TTL_OPTS) if val
87+
val
88+
end
5889
end
5990

6091
end

lib/kracken/rspec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ module TokenAuthenticatable
4646
def current_user
4747
Kracken::SpecHelper.current_user
4848
end
49+
50+
alias_method :__original_auth__, :authenticate_user_with_token!
51+
def authenticate_user_with_token!
52+
if current_user
53+
@_auth_info = {
54+
id: current_user.id,
55+
team_ids: current_user.team_ids,
56+
}
57+
else
58+
__original_auth__
59+
end
60+
end
4961
end
5062
end
5163
end

spec/dummy/app/models/user.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,10 @@ def id
2222
def uid
2323
@hash["uid"]
2424
end
25+
26+
def team_ids
27+
@hash["info"] ||= {}
28+
@hash["info"]["teams"] ||= []
29+
@hash["info"]["teams"].map { |t| t["uid"] }
30+
end
2531
end
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
require "support/base_controller_double"
2+
require "support/using_cache"
3+
4+
module Kracken
5+
class TokenAuthController < BaseControllerDouble
6+
include Kracken::Controllers::TokenAuthenticatable
7+
public :authenticate_user_with_token!
8+
# The module includes things as private so that they are not accidentally
9+
# exposed as controller routes. However, we really treat some of them as the
10+
# "public" API for the module:
11+
public :current_auth_info,
12+
:current_team_ids,
13+
:current_user,
14+
:current_user_id
15+
16+
def authenticate_or_request_with_http_token(realm = nil)
17+
/\AToken token="(?<token>.*)"\z/ =~ request.env['HTTP_AUTHORIZATION']
18+
yield token if block_given?
19+
end
20+
end
21+
22+
RSpec.describe Controllers::TokenAuthenticatable do
23+
describe "authenticating via a token", :using_cache do
24+
subject(:a_controller) { TokenAuthController.new }
25+
26+
shared_examples "the authorization request headers" do |token_helper|
27+
let(:expected_token) { public_send token_helper }
28+
29+
specify "are munged to include a provided parameterized token" do
30+
a_controller.request.env = {
31+
'HTTP_AUTHORIZATION' => 'Token token="header token"'
32+
}
33+
a_controller.params = { token: expected_token }
34+
35+
expect {
36+
a_controller.authenticate_user_with_token!
37+
}.to change {
38+
a_controller.request.env
39+
}.from(
40+
'HTTP_AUTHORIZATION' => 'Token token="header token"'
41+
).to(
42+
'HTTP_AUTHORIZATION' => "Token token=\"#{expected_token}\""
43+
)
44+
end
45+
46+
specify "are not modified when no parameterized token provided" do
47+
a_controller.request.env = {
48+
'HTTP_AUTHORIZATION' => "Token token=\"#{expected_token}\""
49+
}
50+
51+
expect {
52+
a_controller.authenticate_user_with_token!
53+
}.not_to change { a_controller.request.env }.from(
54+
'HTTP_AUTHORIZATION' => "Token token=\"#{expected_token}\""
55+
)
56+
end
57+
end
58+
59+
context "on a cache hit" do
60+
let(:auth_info) {
61+
{
62+
id: :any_id,
63+
team_ids: [:some, :team, :ids],
64+
}
65+
}
66+
let(:cached_token) { "any token" }
67+
let(:cache_key) { "auth/token/any token" }
68+
69+
before do
70+
a_controller.request.env = {
71+
'HTTP_AUTHORIZATION' => "Token token=\"#{cached_token}\""
72+
}
73+
74+
Rails.cache.write cache_key, auth_info
75+
stub_const "Kracken::Authenticator", spy("Kracken::Authenticator")
76+
end
77+
78+
include_examples "the authorization request headers", :cached_token
79+
80+
it "uses the exising cache to bypass the authentication process" do
81+
a_controller.authenticate_user_with_token!
82+
expect(Authenticator).not_to have_received(:user_with_token)
83+
end
84+
85+
it "returns the auth info" do
86+
expect(a_controller.authenticate_user_with_token!).to eq(
87+
id: :any_id,
88+
team_ids: [:some, :team, :ids],
89+
).and be_frozen
90+
end
91+
92+
it "exposes the auth info via the `current_` helpers", :aggregate_failures do
93+
expect {
94+
a_controller.authenticate_user_with_token!
95+
}.to(
96+
change { a_controller.current_auth_info }.from({}).to(auth_info)
97+
.and change { a_controller.current_user_id }.from(nil).to(:any_id)
98+
.and change { a_controller.current_team_ids }.from(nil).to(
99+
[:some, :team, :ids]
100+
)
101+
)
102+
103+
expect(a_controller.current_auth_info).to be_frozen
104+
expect(a_controller.current_team_ids).to be_frozen
105+
end
106+
107+
it "lazy loads the current user" do
108+
begin
109+
# Ensure we cannot lookup a user - doing so would raise an error
110+
org_user_class = Kracken.config.user_class
111+
user_class = double("AnyUserClass")
112+
Kracken.config.user_class = user_class
113+
114+
# Action under test
115+
a_controller.authenticate_user_with_token!
116+
117+
# Make sure we perform the lookup as expected now
118+
expect(user_class).to receive(:find).with(:any_id).and_return(:user)
119+
120+
expect(a_controller.current_user).to be :user
121+
ensure
122+
Kracken.config.user_class = org_user_class
123+
end
124+
end
125+
end
126+
127+
context "on a cache miss with an invalid token" do
128+
let(:invalid_token) { "any token" }
129+
130+
before do
131+
a_controller.request.env = {
132+
'HTTP_AUTHORIZATION' => "Token token=\"#{invalid_token}\""
133+
}
134+
135+
allow(Authenticator).to receive(:user_with_token).with(invalid_token)
136+
.and_return(nil)
137+
end
138+
139+
include_examples "the authorization request headers", :invalid_token
140+
141+
it "follows the token authentication process" do
142+
a_controller.authenticate_user_with_token!
143+
expect(Authenticator).to have_received(:user_with_token)
144+
.with(invalid_token)
145+
end
146+
147+
it "returns nil" do
148+
expect(a_controller.authenticate_user_with_token!).to be nil
149+
end
150+
151+
it "doesn't cache invalid tokens" do
152+
expect {
153+
a_controller.authenticate_user_with_token!
154+
}.not_to change {
155+
Rails.cache.exist?("auth/token/#{invalid_token}")
156+
}.from false
157+
end
158+
end
159+
160+
context "on a cache miss with a valid token" do
161+
let(:a_user) {
162+
instance_double(User, id: user_id, team_ids: some_team_ids)
163+
}
164+
let(:some_team_ids) { [:some, :team, :ids] }
165+
let(:user_id) { :any_id }
166+
let(:valid_token) { "any token" }
167+
168+
before do
169+
a_controller.request.env = {
170+
'HTTP_AUTHORIZATION' => "Token token=\"#{valid_token}\""
171+
}
172+
173+
allow(Authenticator).to receive(:user_with_token).with(valid_token)
174+
.and_return(a_user)
175+
end
176+
177+
include_examples "the authorization request headers", :valid_token
178+
179+
it "follows the token authentication process" do
180+
a_controller.authenticate_user_with_token!
181+
expect(Authenticator).to have_received(:user_with_token)
182+
.with(valid_token)
183+
end
184+
185+
it "returns the auth info in a frozen state" do
186+
expect(a_controller.authenticate_user_with_token!).to eq(
187+
id: :any_id,
188+
team_ids: [:some, :team, :ids],
189+
).and be_frozen
190+
end
191+
192+
it "exposes the auth info via the `current_` helpers", :aggregate_failures do
193+
expect {
194+
a_controller.authenticate_user_with_token!
195+
}.to(
196+
change { a_controller.current_auth_info }.from({}).to(
197+
id: :any_id,
198+
team_ids: [:some, :team, :ids],
199+
)
200+
.and change { a_controller.current_user_id }.from(nil).to(:any_id)
201+
.and change { a_controller.current_team_ids }.from(nil).to(
202+
[:some, :team, :ids]
203+
)
204+
)
205+
206+
expect(a_controller.current_auth_info).to be_frozen
207+
expect(a_controller.current_team_ids).to be_frozen
208+
end
209+
210+
it "sets the auth info as the cache value" do
211+
expect {
212+
a_controller.authenticate_user_with_token!
213+
}.to change { Rails.cache.read("auth/token/any token") }.from(nil).to(
214+
id: :any_id,
215+
team_ids: [:some, :team, :ids],
216+
)
217+
end
218+
219+
it "sets the cache expiration to one minute by default" do
220+
expect(Rails.cache).to receive(:write).with(
221+
"auth/token/any token",
222+
anything,
223+
include(expires_in: 1.minute),
224+
)
225+
a_controller.authenticate_user_with_token!
226+
end
227+
228+
it "eager loads the current user" do
229+
expect(Kracken.config.user_class).not_to receive(:find)
230+
a_controller.authenticate_user_with_token!
231+
expect(a_controller.current_user).to be a_user
232+
end
233+
end
234+
end
235+
end
236+
end

spec/support/base_controller_double.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
module Kracken
22
class BaseControllerDouble
3-
attr_accessor :session, :cookies
3+
Request = Struct.new(:env)
4+
5+
attr_accessor :session, :cookies, :request, :params
46

57
def initialize
68
@session = {}
79
@cookies = {}
10+
@request = Request.new({})
11+
@params = {}
812
end
913

1014
def self.helper_method(*) ; end

spec/support/using_cache.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
RSpec.shared_context "using Rails cache", :using_cache do
2+
before(:context) do
3+
@org_cache = Rails.cache
4+
Rails.cache = ActiveSupport::Cache.lookup_store(:memory_store)
5+
end
6+
7+
after(:context) do
8+
Rails.cache = @org_cache
9+
end
10+
11+
before do
12+
Rails.cache.clear
13+
end
14+
end

0 commit comments

Comments
 (0)