diff --git a/.gitignore b/.gitignore index c46971770..650982cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ test/dummy/.sass-cache test/dummy/config/application.yml coverage .idea +.byebug_history .irb_history .ruby-version .ruby-gemset diff --git a/app/controllers/devise_token_auth/concerns/set_user_by_token.rb b/app/controllers/devise_token_auth/concerns/set_user_by_token.rb index c03bca6ba..a2221b010 100644 --- a/app/controllers/devise_token_auth/concerns/set_user_by_token.rb +++ b/app/controllers/devise_token_auth/concerns/set_user_by_token.rb @@ -35,11 +35,20 @@ def set_user_by_token(mapping = nil) access_token_name = DeviseTokenAuth.headers_names[:'access-token'] client_name = DeviseTokenAuth.headers_names[:'client'] + # gets values from cookie if configured and present + parsed_auth_cookie = {} + if DeviseTokenAuth.cookie_enabled + auth_cookie = request.cookies[DeviseTokenAuth.cookie_name] + if auth_cookie.present? + parsed_auth_cookie = JSON.parse(auth_cookie) + end + end + # parse header for values necessary for authentication - uid = request.headers[uid_name] || params[uid_name] + uid = request.headers[uid_name] || params[uid_name] || parsed_auth_cookie[uid_name] @token = DeviseTokenAuth::TokenFactory.new unless @token - @token.token ||= request.headers[access_token_name] || params[access_token_name] - @token.client ||= request.headers[client_name] || params[client_name] + @token.token ||= request.headers[access_token_name] || params[access_token_name] || parsed_auth_cookie[access_token_name] + @token.client ||= request.headers[client_name] || params[client_name] || parsed_auth_cookie[client_name] # client isn't required, set to 'default' if absent @token.client ||= 'default' @@ -101,6 +110,10 @@ def update_auth_header # update the response header response.headers.merge!(auth_header) + # set a server cookie if configured + if DeviseTokenAuth.cookie_enabled + set_cookie(auth_header) + end else unless @resource.reload.valid? @resource = @resource.class.find(@resource.to_param) # errors remain after reload @@ -123,11 +136,22 @@ def refresh_headers # cleared by sign out in the meantime return if @used_auth_by_token && @resource.tokens[@token.client].nil? + _auth_header_from_batch_request = auth_header_from_batch_request + # update the response header - response.headers.merge!(auth_header_from_batch_request) + response.headers.merge!(_auth_header_from_batch_request) + + # set a server cookie if configured + if DeviseTokenAuth.cookie_enabled + set_cookie(_auth_header_from_batch_request) + end end # end lock end + def set_cookie(auth_header) + cookies[DeviseTokenAuth.cookie_name] = DeviseTokenAuth.cookie_attributes.merge(value: auth_header.to_json) + end + def is_batch_request?(user, client) !params[:unbatch] && user.tokens[client] && diff --git a/app/controllers/devise_token_auth/sessions_controller.rb b/app/controllers/devise_token_auth/sessions_controller.rb index 5d34eaf69..96dc295cd 100644 --- a/app/controllers/devise_token_auth/sessions_controller.rb +++ b/app/controllers/devise_token_auth/sessions_controller.rb @@ -55,6 +55,12 @@ def destroy user.tokens.delete(client) user.save! + if DeviseTokenAuth.cookie_enabled + # If a cookie is set with a domain specified then it must be deleted with that domain specified + # See https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html + cookies.delete(DeviseTokenAuth.cookie_name, domain: DeviseTokenAuth.cookie_attributes[:domain]) + end + yield user if block_given? render_destroy_success diff --git a/docs/config/initialization.md b/docs/config/initialization.md index ecd4c7529..bc7380ee1 100644 --- a/docs/config/initialization.md +++ b/docs/config/initialization.md @@ -15,6 +15,9 @@ The following settings are available for configuration in `config/initializers/d | **`enable_standard_devise_support`** (`false`) | By default, only Bearer Token authentication is implemented out of the box. If, however, you wish to integrate with legacy Devise authentication, you can do so by enabling this flag. NOTE: This feature is highly experimental! | | **`remove_tokens_after_password_reset`** (`false`) | By default, old tokens are not invalidated when password is changed. Enable this option if you want to make passwords updates to logout other devices. | | **`default_callbacks`** (`true`) | By default User model will include the `DeviseTokenAuth::Concerns::UserOmniauthCallbacks` concern, which has `email`, `uid` validations & `uid` synchronization callbacks. | +| **`cookie_enabled`** (`false`) | Specifies if DeviseTokenAuth should send and receive the auth token in a cookie. +| **`cookie_name`** (`"auth_cookie"`) | Sets the name of the cookie containing the auth token. +| **`cookie_attributes`** (`{}`) | Sets attributes for the cookie containing the auth token (ex. `domain`, `secure`, `httponly`, `same_site`, `expires`, `encrypt`). See [this Rails doc](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html) for what values can be passed to `ActionDispatch::Cookies`. See [this MDN doc](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) for additional information on HTTP cookie attributes. | **`bypass_sign_in`** (`true`) | By default DeviseTokenAuth will not check user's `#active_for_authentication?` which includes confirmation check on each call (it will do it only on sign in). If you want it to be validated on each request (for example, to be able to deactivate logged in users on the fly), set it to false. | | **`send_confirmation_email`** (`false`) | By default DeviseTokenAuth will not send confirmation email, even when including devise confirmable module. If you want to use devise confirmable module and send email, set it to true. (This is a setting for compatibility) | | **`require_client_password_reset_token`** (`false`) | By default, the password-reset confirmation link redirects to the client with valid session credentials as querystring params. With this option enabled, the redirect will NOT include the valid session credentials. Instead the redirect will include a password_reset_token querystring param that can be used to reset the users password. Once the user has reset their password, the password-reset success response headers will contain valid session credentials. | diff --git a/lib/devise_token_auth/engine.rb b/lib/devise_token_auth/engine.rb index 4e8bc8ab6..e6a921aa9 100644 --- a/lib/devise_token_auth/engine.rb +++ b/lib/devise_token_auth/engine.rb @@ -25,6 +25,9 @@ class Engine < ::Rails::Engine :remove_tokens_after_password_reset, :default_callbacks, :headers_names, + :cookie_enabled, + :cookie_name, + :cookie_attributes, :bypass_sign_in, :send_confirmation_email, :require_client_password_reset_token @@ -47,6 +50,9 @@ class Engine < ::Rails::Engine 'expiry': 'expiry', 'uid': 'uid', 'token-type': 'token-type' } + self.cookie_enabled = false + self.cookie_name = 'auth_cookie' + self.cookie_attributes = {} self.bypass_sign_in = true self.send_confirmation_email = false self.require_client_password_reset_token = false diff --git a/test/controllers/devise_token_auth/registrations_controller_test.rb b/test/controllers/devise_token_auth/registrations_controller_test.rb index 718b5897b..8a0eadf4e 100644 --- a/test/controllers/devise_token_auth/registrations_controller_test.rb +++ b/test/controllers/devise_token_auth/registrations_controller_test.rb @@ -10,6 +10,17 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::IntegrationTest describe DeviseTokenAuth::RegistrationsController do + + def mock_registration_params + { + email: Faker::Internet.email, + password: 'secret123', + password_confirmation: 'secret123', + confirm_success_url: Faker::Internet.url, + unpermitted_param: '(x_x)' + } + end + describe 'Validate non-empty body' do before do # need to post empty data @@ -41,13 +52,7 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::Integration @mails_sent = ActionMailer::Base.deliveries.count post '/auth', - params: { - email: Faker::Internet.email, - password: 'secret123', - password_confirmation: 'secret123', - confirm_success_url: Faker::Internet.url, - unpermitted_param: '(x_x)' - } + params: mock_registration_params @resource = assigns(:resource) @data = JSON.parse(response.body) @@ -87,17 +92,10 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::Integration before do @original_duration = Devise.allow_unconfirmed_access_for Devise.allow_unconfirmed_access_for = nil - post '/auth', - params: { - email: Faker::Internet.email, - password: 'secret123', - password_confirmation: 'secret123', - confirm_success_url: Faker::Internet.url, - unpermitted_param: '(x_x)' - } end test 'auth headers were returned in response' do + post '/auth', params: mock_registration_params assert response.headers['access-token'] assert response.headers['token-type'] assert response.headers['client'] @@ -105,6 +103,21 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::Integration assert response.headers['uid'] end + describe 'using auth cookie' do + before do + DeviseTokenAuth.cookie_enabled = true + end + + test 'auth cookie was returned in response' do + post '/auth', params: mock_registration_params + assert response.cookies[DeviseTokenAuth.cookie_name] + end + + after do + DeviseTokenAuth.cookie_enabled = false + end + end + after do Devise.allow_unconfirmed_access_for = @original_duration end diff --git a/test/controllers/devise_token_auth/sessions_controller_test.rb b/test/controllers/devise_token_auth/sessions_controller_test.rb index 507daf102..8a2a45b61 100644 --- a/test/controllers/devise_token_auth/sessions_controller_test.rb +++ b/test/controllers/devise_token_auth/sessions_controller_test.rb @@ -17,11 +17,12 @@ class DeviseTokenAuth::SessionsControllerTest < ActionController::TestCase describe 'success' do before do - post :create, - params: { - email: @existing_user.email, - password: @existing_user.password - } + @user_session_params = { + email: @existing_user.email, + password: @existing_user.password + } + + post :create, params: @user_session_params @resource = assigns(:resource) @data = JSON.parse(response.body) @@ -35,17 +36,27 @@ class DeviseTokenAuth::SessionsControllerTest < ActionController::TestCase assert_equal @existing_user.email, @data['data']['email'] end + describe 'using auth cookie' do + before do + DeviseTokenAuth.cookie_enabled = true + end + + test 'request should return auth cookie' do + post :create, params: @user_session_params + assert response.cookies[DeviseTokenAuth.cookie_name] + end + + after do + DeviseTokenAuth.cookie_enabled = false + end + end + describe "with multiple clients and headers don't change in each request" do before do # Set the max_number_of_devices to a lower number # to expedite tests! (Default is 10) DeviseTokenAuth.max_number_of_devices = 2 DeviseTokenAuth.change_headers_on_each_request = false - - @user_session_params = { - email: @existing_user.email, - password: @existing_user.password - } end test 'should limit the maximum number of concurrent devices' do @@ -159,6 +170,24 @@ def @controller.reset_session test 'session was destroyed' do assert_equal true, @controller.reset_session_called end + + describe 'using auth cookie' do + before do + DeviseTokenAuth.cookie_enabled = true + @auth_token = @existing_user.create_new_auth_token + @controller.send(:cookies)[DeviseTokenAuth.cookie_name] = { value: @auth_token.to_json } + end + + test 'auth cookie was destroyed' do + assert_equal @auth_token.to_json, @controller.send(:cookies)[DeviseTokenAuth.cookie_name] # sanity check + delete :destroy, format: :json + assert_nil @controller.send(:cookies)[DeviseTokenAuth.cookie_name] + end + + after do + DeviseTokenAuth.cookie_enabled = false + end + end end describe 'unauthed user sign out' do