diff --git a/SUMMARY.md b/SUMMARY.md index 44f8bc9e6..d9d499666 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -14,6 +14,7 @@ * [Using Multiple User Classes](docs/usage/multiple_models.md) * [Excluding Modules](docs/usage/excluding_models.md) * [Custom Controller/Email Overrides](docs/usage/overrides.md) + * [Reset password flow](docs/usage/reset_password.md) * [Testing](docs/usage/testing.md) * [FAQ](docs/faq.md) * [Conceptual Diagrams](docs/conceptual.md) diff --git a/app/controllers/devise_token_auth/passwords_controller.rb b/app/controllers/devise_token_auth/passwords_controller.rb index 2c3e59516..bf8340220 100644 --- a/app/controllers/devise_token_auth/passwords_controller.rb +++ b/app/controllers/devise_token_auth/passwords_controller.rb @@ -2,12 +2,10 @@ module DeviseTokenAuth class PasswordsController < DeviseTokenAuth::ApplicationController - before_action :set_user_by_token, only: [:update] before_action :validate_redirect_url_param, only: [:create, :edit] skip_after_action :update_auth_header, only: [:create, :edit] - # this action is responsible for generating password reset tokens and - # sending emails + # this action is responsible for generating password reset tokens and sending emails def create return render_create_error_missing_email unless resource_params[:email] @@ -39,11 +37,10 @@ def edit @resource = resource_class.with_reset_password_token(resource_params[:reset_password_token]) if @resource && @resource.reset_password_period_valid? - token = @resource.create_token + token = @resource.create_token unless require_client_password_reset_token? # ensure that user is confirmed @resource.skip_confirmation! if confirmable_enabled? && !@resource.confirmed_at - # allow user to change password once without current_password @resource.allow_password_change = true if recoverable_enabled? @@ -51,12 +48,16 @@ def edit yield @resource if block_given? - redirect_header_options = { reset_password: true } - redirect_headers = build_redirect_headers(token.token, - token.client, - redirect_header_options) - redirect_to(@resource.build_auth_url(@redirect_url, - redirect_headers)) + if require_client_password_reset_token? + redirect_to DeviseTokenAuth::Url.generate(@redirect_url, reset_password_token: resource_params[:reset_password_token]) + else + redirect_header_options = { reset_password: true } + redirect_headers = build_redirect_headers(token.token, + token.client, + redirect_header_options) + redirect_to(@resource.build_auth_url(@redirect_url, + redirect_headers)) + end else render_edit_error end @@ -64,6 +65,15 @@ def edit def update # make sure user is authorized + if require_client_password_reset_token? && resource_params[:reset_password_token] + @resource = resource_class.with_reset_password_token(resource_params[:reset_password_token]) + return render_update_error_unauthorized unless @resource + + @token = @resource.create_token + else + @resource = set_user_by_token + end + return render_update_error_unauthorized unless @resource # make sure account doesn't use oauth2 provider @@ -90,7 +100,7 @@ def update protected def resource_update_method - allow_password_change = recoverable_enabled? && @resource.allow_password_change == true + allow_password_change = recoverable_enabled? && @resource.allow_password_change == true || require_client_password_reset_token? if DeviseTokenAuth.check_current_password_before_update == false || allow_password_change 'update' else @@ -184,5 +194,13 @@ def validate_redirect_url_param return render_create_error_missing_redirect_url unless @redirect_url return render_error_not_allowed_redirect_url if blacklisted_redirect_url? end + + def reset_password_token_as_raw?(recoverable) + recoverable && recoverable.reset_password_token.present? && !require_client_password_reset_token? + end + + def require_client_password_reset_token? + DeviseTokenAuth.require_client_password_reset_token + end end end diff --git a/docs/config/initialization.md b/docs/config/initialization.md index 0881b6fec..6785c5794 100644 --- a/docs/config/initialization.md +++ b/docs/config/initialization.md @@ -16,6 +16,7 @@ The following settings are available for configuration in `config/initializers/d | **`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. | | **`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. | +| **`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. | Additionally, you can configure other aspects of devise by manually creating the traditional devise.rb file at `config/initializers/devise.rb`. Here are some examples of what you can do in this file: diff --git a/docs/faq.md b/docs/faq.md index 05d85e2cc..633975223 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -108,40 +108,6 @@ You may be interested in [GrapeTokenAuth](https://github.com/mcordell/grape_toke You may be interested in [solidus_devise_token_auth](https://github.com/skycocker/solidus_devise_token_auth). -### What's the reset password flow? - -This is the overall workflow for a User to reset their password: - -- user goes to a page on the front end site which contains a form with a single text field, they type their email address into this field and click a button to submit the form - -- that form submission sends a request to the API: `POST /auth/password` with some parameters: `email` (the email supplied in the field) & `redirect_url` (a page in the front end site that will contain a form with `password` and `password_confirmation` fields) - -- the API responds to this request by generating a `reset_password_token` and sending an email (the `reset_password_instructions.html.erb` file from devise) to the email address provided within the `email` parameter - - we need to modify the `reset_password_instructions.html.erb` file to point to the API: `GET /auth/password/edit` - - for example, if you have your API under the `api/v1` namespaces: `<%= link_to 'Change my password', edit_api_v1_user_password_url(reset_password_token: @token, config: message['client-config'].to_s, redirect_url: message['redirect-url'].to_s) %>` (I came up with this `link_to` by referring to [this line](https://github.com/lynndylanhurley/devise_token_auth/blob/15bf7857eca2d33602c7a9cb9d08db8a160f8ab8/app/views/devise/mailer/reset_password_instructions.html.erb#L5)) - -- the user clicks the link in the email, which brings them to the 'Verify user by password reset token' endpoint (`GET /password/edit`) - -- this endpoint verifies the user and redirects them to the `redirect_url` (or the one you set in an initializer as default_password_reset_url) with the auth headers if they are who they claim to be (if their `reset_password_token` matches a User record) - -- this `redirect_url` is a page on the frontend which contains a `password` and `password_confirmation` field - -- the user submits the form on this frontend page, which sends a request to API: `PUT /auth/password` with the `password` and `password_confirmation` parameters. In addition headers need to be included from the url params (you get these from the url as query params). A side note, ensure that the header names follow the convention outlined in `config/initializers/devise_token_auth.rb`; at this time of writing it is: `uid`, `client` and `access-token`. - - _Ensure that the `uid` sent in the headers is not URL-escaped. e.g. it should be bob@example.com, not bob%40example.com_ - -- the API changes the user's password and responds back with a success message - -- the front end needs to manually redirect the user to its login page after receiving this success response - -- the user logs in - -The next diagram shows how it works: - -![password reset flow](password_diagram_reset.jpg) - -If you get in any trouble configuring or overriding the behavior, you can check the [issue #604](https://github.com/lynndylanhurley/devise_token_auth/issues/604). - - ### I already have a user, how can I add the new fields? 1. First, remove the migration generated by the following command`rails g devise_token_auth:install [USER_CLASS] [MOUNT_PATH]` and then:. diff --git a/docs/usage/reset_password.md b/docs/usage/reset_password.md new file mode 100644 index 000000000..f296b0850 --- /dev/null +++ b/docs/usage/reset_password.md @@ -0,0 +1,47 @@ +## Reset password flow + +As a requirement you need to have `allow_password_change` field for any flow. There are 2 flows for reseting the password, one more web focused and other more mobile focused: + +### Main reset password flow (use auth headers) + +This is the overall workflow for a User to reset their password: + +- user goes to a page on the front end site which contains a form with a single text field, they type their email address into this field and click a button to submit the form + +- that form submission sends a request to the API: `POST /auth/password` with some parameters: `email` (the email supplied in the field) & `redirect_url` (a page in the front end site that will contain a form with `password` and `password_confirmation` fields) + +- the API responds to this request by generating a `reset_password_token` and sending an email (the `reset_password_instructions.html.erb` file from devise) to the email address provided within the `email` parameter + - we need to modify the `reset_password_instructions.html.erb` file to point to the API: `GET /auth/password/edit` + - for example, if you have your API under the `api/v1` namespaces: `<%= link_to 'Change my password', edit_api_v1_user_password_url(reset_password_token: @token, config: message['client-config'].to_s, redirect_url: message['redirect-url'].to_s) %>` (I came up with this `link_to` by referring to [this line](https://github.com/lynndylanhurley/devise_token_auth/blob/15bf7857eca2d33602c7a9cb9d08db8a160f8ab8/app/views/devise/mailer/reset_password_instructions.html.erb#L5)) + +- the user clicks the link in the email, which brings them to the 'Verify user by password reset token' endpoint (`GET /password/edit`) + +- this endpoint verifies the user and redirects them to the `redirect_url` (or the one you set in an initializer as default_password_reset_url) with the auth headers if they are who they claim to be (if their `reset_password_token` matches a User record) + +- this `redirect_url` is a page on the frontend which contains a `password` and `password_confirmation` field + +- the user submits the form on this frontend page, which sends a request to API: `PUT /auth/password` with the `password` and `password_confirmation` parameters. In addition headers need to be included from the url params (you get these from the url as query params). A side note, ensure that the header names follow the convention outlined in `config/initializers/devise_token_auth.rb`; at this time of writing it is: `uid`, `client` and `access-token`. + - _Ensure that the `uid` sent in the headers is not URL-escaped. e.g. it should be bob@example.com, not bob%40example.com_ + +- the API changes the user's password and responds back with a success message + +- the front end needs to manually redirect the user to its login page after receiving this success response + +- the user logs in + +The next diagram shows how it works: + +![password reset flow](password_diagram_reset.jpg) + +If you get in any trouble configuring or overriding the behavior, you can check the [issue #604](https://github.com/lynndylanhurley/devise_token_auth/issues/604). + +### Mobile alternative flow (use reset_password_token) + +This flow is enabled with `require_client_password_reset_token` (by default is false), it is also useful for webs. This flow was done because the main one doesn't support deep linking (if you want to reset the password in the mobile app). It works like the main one but instead of receiving and sending the auth headers, you need to send the `reset_password_token`, but just in case, we can explain it step by step: + +1. User fills out password reset request form (this POST `/auth/password`) +2. User is sent an email +3. User clicks confirmation link (this GET `/auth/password/edit`) +4. Link leads to the client to the `redirect_url` (instead of the API) with a `reset_password_token` +5. User submits password along with reset_password_token (this PUT `/auth/password`) +6. User is now authorized and has a new password diff --git a/lib/devise_token_auth/engine.rb b/lib/devise_token_auth/engine.rb index 7cb204bd0..72ec32f23 100644 --- a/lib/devise_token_auth/engine.rb +++ b/lib/devise_token_auth/engine.rb @@ -25,7 +25,8 @@ class Engine < ::Rails::Engine :remove_tokens_after_password_reset, :default_callbacks, :headers_names, - :bypass_sign_in + :bypass_sign_in, + :require_client_password_reset_token self.change_headers_on_each_request = true self.max_number_of_devices = 10 @@ -46,6 +47,7 @@ class Engine < ::Rails::Engine 'uid': 'uid', 'token-type': 'token-type' } self.bypass_sign_in = true + self.require_client_password_reset_token = false def self.setup(&block) yield self diff --git a/test/controllers/devise_token_auth/passwords_controller_test.rb b/test/controllers/devise_token_auth/passwords_controller_test.rb index 09c9d0a0e..80128a4d3 100644 --- a/test/controllers/devise_token_auth/passwords_controller_test.rb +++ b/test/controllers/devise_token_auth/passwords_controller_test.rb @@ -239,10 +239,10 @@ class DeviseTokenAuth::PasswordsControllerTest < ActionController::TestCase end end - describe 'Cheking reset_password_token' do + describe 'Checking reset_password_token' do before do post :create, params: { - email: @resource.email, + email: @resource.email, redirect_url: @redirect_url } @@ -440,6 +440,7 @@ class DeviseTokenAuth::PasswordsControllerTest < ActionController::TestCase describe 'success' do before do + DeviseTokenAuth.require_client_password_reset_token = false @auth_headers = @resource.create_new_auth_token request.headers.merge!(@auth_headers) @new_password = Faker::Internet.password @@ -504,6 +505,7 @@ class DeviseTokenAuth::PasswordsControllerTest < ActionController::TestCase describe 'current password mismatch error' do before do + DeviseTokenAuth.require_client_password_reset_token = false @auth_headers = @resource.create_new_auth_token request.headers.merge!(@auth_headers) @new_password = Faker::Internet.password @@ -520,7 +522,35 @@ class DeviseTokenAuth::PasswordsControllerTest < ActionController::TestCase end describe 'change password' do - describe 'success' do + describe 'using reset token' do + before do + DeviseTokenAuth.require_client_password_reset_token = true + @redirect_url = 'http://client-app.dev' + get_reset_token + edit_url = CGI.unescape(@mail.body.match(/href=\"(.+)\"/)[1]) + query_parts = Rack::Utils.parse_nested_query(URI.parse(edit_url).query) + get :edit, params: query_parts + end + + test 'request should be redirect' do + assert_equal 302, response.status + end + + test 'request should redirect to correct redirect url' do + host = URI.parse(response.location).host + query_parts = Rack::Utils.parse_nested_query(URI.parse(response.location).query) + + assert_equal 'client-app.dev', host + assert_equal @mail_reset_token, query_parts['reset_password_token'] + assert_equal 1, query_parts.keys.size + end + + teardown do + DeviseTokenAuth.require_client_password_reset_token = false + end + end + + describe 'with valid headers' do before do @auth_headers = @resource.create_new_auth_token request.headers.merge!(@auth_headers) @@ -567,19 +597,93 @@ class DeviseTokenAuth::PasswordsControllerTest < ActionController::TestCase end end - describe 'unauthorized user' do + describe 'without valid headers' do before do - @auth_headers = @resource.create_new_auth_token - @new_password = Faker::Internet.password + @resource.create_new_auth_token + new_password = Faker::Internet.password - put :update, params: { password: @new_password, - password_confirmation: @new_password } + put :update, params: { password: new_password, + password_confirmation: new_password } end test 'response should fail' do assert_equal 401, response.status end end + + describe 'with valid reset password token' do + before do + reset_password_token = @resource.send_reset_password_instructions + @new_password = Faker::Internet.password + @params = { password: @new_password, + password_confirmation: @new_password, + reset_password_token: reset_password_token } + end + + describe 'with require_client_password_reset_token disabled' do + before do + DeviseTokenAuth.require_client_password_reset_token = false + put :update, params: @params + + @data = JSON.parse(response.body) + @resource.reload + end + + test 'request should be not be successful' do + assert_equal 401, response.status + end + end + + describe 'with require_client_password_reset_token enabled' do + before do + DeviseTokenAuth.require_client_password_reset_token = true + put :update, params: @params + + @data = JSON.parse(response.body) + @resource.reload + end + + test 'request should be successful' do + assert_equal 200, response.status + end + + test 'request should return success message' do + assert @data['message'] + assert_equal @data['message'], + I18n.t('devise_token_auth.passwords.successfully_updated') + end + + test 'new password should authenticate user' do + assert @resource.valid_password?(@new_password) + end + + teardown do + DeviseTokenAuth.require_client_password_reset_token = false + end + end + end + + describe 'with invalid reset password token' do + before do + DeviseTokenAuth.require_client_password_reset_token = true + @resource.update reset_password_token: 'koskoskoskos' + put :update, params: @params + @data = JSON.parse(response.body) + @resource.reload + end + + test 'request should fail' do + assert_equal 401, response.status + end + + test 'new password should not authenticate user' do + assert !@resource.valid_password?(@new_password) + end + + teardown do + DeviseTokenAuth.require_client_password_reset_token = false + end + end end end diff --git a/test/controllers/devise_token_auth/registrations_controller_test.rb b/test/controllers/devise_token_auth/registrations_controller_test.rb index 06c0bd602..718b5897b 100644 --- a/test/controllers/devise_token_auth/registrations_controller_test.rb +++ b/test/controllers/devise_token_auth/registrations_controller_test.rb @@ -492,7 +492,7 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::Integration # test valid update param @resource_class = User @new_operating_thetan = 1_000_000 - @email = 'AlternatingCase2@example.com' + @email = Faker::Internet.safe_email @request_params = { operating_thetan: @new_operating_thetan, email: @email @@ -599,7 +599,7 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::Integration # test valid update param @resource_class = User @new_operating_thetan = 1_000_000 - @email = 'AlternatingCase2@example.com' + @email = Faker::Internet.safe_email @request_params = { operating_thetan: @new_operating_thetan, email: @email @@ -650,7 +650,7 @@ class DeviseTokenAuth::RegistrationsControllerTest < ActionDispatch::Integration before do DeviseTokenAuth.check_current_password_before_update = :password @new_operating_thetan = 1_000_000 - @email = 'AlternatingCase2@example.com' + @email = Faker::Internet.safe_email end after do diff --git a/test/factories/users.rb b/test/factories/users.rb index f0e4572dd..c1feee688 100644 --- a/test/factories/users.rb +++ b/test/factories/users.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :user do - email { Faker::Internet.safe_email } + email { Faker::Internet.unique.safe_email } password { Faker::Internet.password } provider { 'email' }