Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow password reset with token alone #1295

Merged
1 change: 1 addition & 0 deletions SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 40 additions & 12 deletions app/controllers/devise_token_auth/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -39,31 +37,43 @@ 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? && [email protected]_at

# allow user to change password once without current_password
@resource.allow_password_change = true if recoverable_enabled?

@resource.save!

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 build_callback_url(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
end

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
Expand All @@ -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
Expand Down Expand Up @@ -184,5 +194,23 @@ 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 build_callback_url(reset_password_token)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't you use DeviseTokenAuth::Url.generate for this?

url = URI.parse(@redirect_url)
query_params = Rack::Utils.parse_nested_query(url.query)
query_params = query_params.merge(reset_password_token: reset_password_token)
query_string = query_params.collect { |k, v| "#{k}=#{v}" }.join('&')
url.query = query_string

url.to_s
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
1 change: 1 addition & 0 deletions docs/config/initialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 0 additions & 34 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected], 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:.
Expand Down
47 changes: 47 additions & 0 deletions docs/usage/reset_password.md
Original file line number Diff line number Diff line change
@@ -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 [email protected], 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
4 changes: 3 additions & 1 deletion lib/devise_token_auth/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
92 changes: 84 additions & 8 deletions test/controllers/devise_token_auth/passwords_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -520,7 +522,7 @@ class DeviseTokenAuth::PasswordsControllerTest < ActionController::TestCase
end

describe 'change password' do
describe 'success' do
describe 'with valid headers' do
before do
@auth_headers = @resource.create_new_auth_token
request.headers.merge!(@auth_headers)
Expand Down Expand Up @@ -567,19 +569,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 [email protected]_password?(@new_password)
end

teardown do
DeviseTokenAuth.require_client_password_reset_token = false
end
end
end
end

Expand Down
Loading