Skip to content

Commit a9c7ea6

Browse files
Evan-Mzachfeldman
authored andcommitted
Max number of devices in new session (#1115)
* Refactor tests for old token removal when max clients are exceeded. * Remove superfluous call to `#create_new_auth_token`. * There was prior discussion around removing this line of code, inside #990. See: #990 (comment) * While line in question _is_ superfluous, removing it was blocked by a bad test. This test was corrected in the previous commit (9ebc5bd). * Simplify complex conditionals. * Refactor `#clean_old_tokens` to reduce computational complexity. * Previous version featured an `Enumerable#min_by` loop _inside_ a `while` loop, resulting in `O(n^2)` complexity. * Instead, break things into two separate loops, and skip altogether if they aren't even necessary. * Apply small changes per the PR review.
1 parent 38d27cf commit a9c7ea6

File tree

4 files changed

+101
-29
lines changed

4 files changed

+101
-29
lines changed

app/controllers/devise_token_auth/concerns/set_user_by_token.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ def set_user_by_token(mapping=nil)
6060
if devise_warden_user && devise_warden_user.tokens[@client_id].nil?
6161
@used_auth_by_token = false
6262
@resource = devise_warden_user
63-
@resource.create_new_auth_token
63+
# REVIEW: The following line _should_ be safe to remove;
64+
# the generated token does not get used anywhere.
65+
# @resource.create_new_auth_token
6466
end
6567
end
6668

app/models/devise_token_auth/concerns/user.rb

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,31 @@ def destroy_expired_tokens
244244
end
245245

246246
def remove_tokens_after_password_reset
247-
should_remove_old_tokens = DeviseTokenAuth.remove_tokens_after_password_reset &&
248-
encrypted_password_changed? && tokens && tokens.many?
247+
return unless encrypted_password_changed? &&
248+
DeviseTokenAuth.remove_tokens_after_password_reset
249249

250-
if should_remove_old_tokens
250+
if tokens.present? && tokens.many?
251251
client_id, token_data = tokens.max_by { |cid, v| v[:expiry] || v["expiry"] }
252252
self.tokens = {client_id => token_data}
253253
end
254254
end
255255

256+
def max_client_tokens_exceeded?
257+
tokens.length > DeviseTokenAuth.max_number_of_devices
258+
end
259+
256260
def clean_old_tokens
257-
while tokens.length > 0 && DeviseTokenAuth.max_number_of_devices < tokens.length
258-
oldest_client_id, _tk = tokens.min_by { |_cid, v| v[:expiry] || v["expiry"] }
259-
tokens.delete(oldest_client_id)
261+
if tokens.present? && max_client_tokens_exceeded?
262+
# Using Enumerable#sort_by on a Hash will typecast it into an associative
263+
# Array (i.e. an Array of key-value Array pairs). However, since Hashes
264+
# have an internal order in Ruby 1.9+, the resulting sorted associative
265+
# Array can be converted back into a Hash, while maintaining the sorted
266+
# order.
267+
self.tokens = tokens.sort_by { |_cid, v| v[:expiry] || v['expiry'] }.to_h
268+
269+
# Since the tokens are sorted by expiry, shift the oldest client token
270+
# off the Hash until it no longer exceeds the maximum number of clients
271+
tokens.shift while max_client_tokens_exceeded?
260272
end
261273
end
262274
end

test/controllers/demo_user_controller_test.rb

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,50 @@ class DemoUserControllerTest < ActionDispatch::IntegrationTest
407407
DeviseTokenAuth.headers_names[:'access-token'] = 'access-token'
408408
end
409409
end
410+
411+
describe 'maximum concurrent devices per user' do
412+
before do
413+
# Set the max_number_of_devices to a lower number
414+
# to expedite tests! (Default is 10)
415+
DeviseTokenAuth.max_number_of_devices = 5
416+
end
417+
418+
it 'should limit the maximum number of concurrent devices' do
419+
# increment the number of devices until the maximum is exceeded
420+
1.upto(DeviseTokenAuth.max_number_of_devices + 1).each do |n|
421+
422+
assert_equal(
423+
[n, DeviseTokenAuth.max_number_of_devices].min,
424+
@resource.reload.tokens.length
425+
)
426+
427+
# Add a new device (and token) ahead of the next iteration
428+
@resource.create_new_auth_token
429+
430+
end
431+
end
432+
433+
it 'should drop the oldest token when the maximum number of devices is exceeded' do
434+
# create the maximum number of tokens
435+
1.upto(DeviseTokenAuth.max_number_of_devices).each do
436+
@resource.create_new_auth_token
437+
end
438+
439+
# get the oldest token client_id
440+
oldest_client_id, = @resource.reload.tokens.min_by do |cid, v|
441+
v[:expiry] || v["expiry"]
442+
end # => [ 'CLIENT_ID', {token: ...} ]
443+
444+
# create another token, thereby dropping the oldest token
445+
@resource.create_new_auth_token
446+
447+
assert_not_includes @resource.reload.tokens.keys, oldest_client_id
448+
end
449+
450+
after do
451+
DeviseTokenAuth.max_number_of_devices = 10
452+
end
453+
end
410454
end
411455

412456
describe 'bypass_sign_in' do
@@ -503,17 +547,8 @@ class DemoUserControllerTest < ActionDispatch::IntegrationTest
503547
refute_equal @resource, @controller.current_mang
504548
end
505549

506-
it 'should increase the number of tokens by a factor of 2 up to 11' do
507-
@first_token = @resource.tokens.keys.first
508550

509-
DeviseTokenAuth.max_number_of_devices = 11
510-
(1..10).each do |n|
511-
assert_equal [11, 2 * n].min, @resource.reload.tokens.keys.length
512-
get '/demo/members_only', params: {}, headers: nil
513-
end
514551

515-
assert_not_includes @resource.reload.tokens.keys, @first_token
516-
end
517552
end
518553

519554
it 'should return success status' do

test/controllers/devise_token_auth/sessions_controller_test.rb

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,23 +75,46 @@ class DeviseTokenAuth::SessionsControllerTest < ActionController::TestCase
7575

7676
describe "with multiple clients and headers don't change in each request" do
7777
before do
78-
DeviseTokenAuth.max_number_of_devices = 1
78+
# Set the max_number_of_devices to a lower number
79+
# to expedite tests! (Default is 10)
80+
DeviseTokenAuth.max_number_of_devices = 2
7981
DeviseTokenAuth.change_headers_on_each_request = false
80-
@tokens = []
81-
(1..3).each do |n|
82-
post :create,
83-
params: {
84-
email: @existing_user.email,
85-
password: 'secret123'
86-
}
87-
@tokens << @existing_user.reload.tokens
82+
83+
@user_session_params = {
84+
email: @existing_user.email,
85+
password: 'secret123'
86+
}
87+
end
88+
89+
test 'should limit the maximum number of concurrent devices' do
90+
# increment the number of devices until the maximum is exceeded
91+
1.upto(DeviseTokenAuth.max_number_of_devices + 1).each do |n|
92+
initial_tokens = @existing_user.reload.tokens
93+
94+
assert_equal(
95+
[n, DeviseTokenAuth.max_number_of_devices].min,
96+
@existing_user.reload.tokens.length
97+
)
98+
99+
# Already have the max number of devices
100+
post :create, params: @user_session_params
101+
102+
# A session for a new device maintains the max number of concurrent devices
103+
refute_equal initial_tokens, @existing_user.reload.tokens
88104
end
89105
end
90106

91-
test 'should delete old tokens' do
92-
current_tokens = @existing_user.reload.tokens
93-
assert_equal 1, current_tokens.count
94-
assert_equal @tokens.pop.keys.first, current_tokens.keys.first
107+
test 'should drop old tokens when max number of devices is exceeded' do
108+
1.upto(DeviseTokenAuth.max_number_of_devices).each do |n|
109+
post :create, params: @user_session_params
110+
end
111+
112+
oldest_token, _ = @existing_user.reload.tokens \
113+
.min_by { |cid, v| v[:expiry] || v["expiry"] }
114+
115+
post :create, params: @user_session_params
116+
117+
assert_not_includes @existing_user.reload.tokens.keys, oldest_token
95118
end
96119

97120
after do

0 commit comments

Comments
 (0)