Having completed account activation (and thereby verified the user’s email address) in Chapter 11, we’re now in a good position to implement password reset, and thereby handle the common case of users forgetting their passwords.1 As we’ll see, many of the steps are similar to those for account activation, and we will have several opportunities to apply the lessons learned in Chapter 11. The beginning is different, though; unlike account activation, implementing password resets requires both a change to one of our views and two new forms (to handle email and new password submission).
1. This chapter is independent of Chapter 11 apart from using the mailer generation in Listing 11.6, but it closely parallels Chapter 11, so it’s much easier if you’ve followed that chapter first.
Before writing any code, let’s mock up the expected sequence for resetting passwords. We’ll start by adding a “forgot password” link to the sample application’s login form (Figure 12.1). The “forgot password” link will go to a page with a form that takes in an email address and sends an email containing a password reset link (Figure 12.2). The reset link will go to a form for resetting the user’s password (with confirmation), as shown in Figure 12.3.
If you followed Chapter 11, you already have a mailer for password resets, which was generated in Section 11.2 (Listing 11.6). In this section, we’ll complete the necessary preliminaries by adding a resource and data model for password resets (Section 12.1) to go along with the mailer. We’ll implement the actual password reset in Section 12.3.
In analogy with account activations, our general plan is to make a Password Resets resource, with each password reset consisting of a reset token and corresponding reset digest. The primary sequence goes like this:
When a user requests a password reset, find the user by the submitted email address.
If the email address exists in the database, generate a reset token and corresponding reset digest.
Save the reset digest to the database, and then send an email to the user with a link containing the reset token and the user’s email address.
When the user clicks the link, find the user by email address, and then authenticate the token by comparing it to the reset digest.
If authenticated, present the user with the form for changing the password.
As with sessions (Section 8.1) and account activations (Chapter 11), we’ll model password resets as a resource even though they won’t be associated with an Active Record model. Instead, we’ll include the relevant data (including the reset token) in the User model itself.
Because we’ll be treating password resets as a resource, we’ll interact with them via the standard REST URLs. Unlike the activation link, which required only an edit
action, in this case we’ll be rendering both new
and edit
forms for manipulating password resets, as well as creating and updating them, so we’ll end up using four RESTful routes in total.
As usual, we’ll make a topic branch for the new feature:
$ git checkout -b password-reset
Our first step is to generate a controller for the Password Resets resource, in this case making both new
and edit
actions per the discussion above:
$ rails generate controller PasswordResets new edit --no-test-framework
Note that we’ve included a flag to skip generating tests. This is because we don’t need the controller tests, preferring instead to build on the integration test from Section 11.3.3.
Because we’ll need forms both for creating new password resets (Figure 12.2) and for updating them by changing the password in the User model (Figure 12.3), we need routes for new
, create
, edit
, and update
. We can arrange this with the resources
line shown in Listing 12.1 (which also deletes the generated routes added by rails generate controller
).
Rails.application.routes.draw do
root "static_pages#home"
get "/help", to: "static_pages#help"
get "/about", to: "static_pages#about"
get "/contact", to: "static_pages#contact"
get "/signup", to: "users#new"
get "/login", to: "sessions#new"
post "/login", to: "sessions#create"
delete "/logout", to: "sessions#destroy"
resources :users
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
end
The code in Listing 12.1 arranges for the RESTful routes shown in Table 12.1. In particular, the first route in Table 12.1 gives a link to the “forgot password” form via
Table 12.1: RESTful routes provided by the Password Resets resource in Listing 12.1.
HTTP request method | URL | Action | Named route |
---|---|---|---|
| /password_resets/new |
|
|
| /password_resets |
|
|
| /password_resets/<token> /edit |
|
|
| /password_resets/<token> |
|
|
new_password_reset_path
as seen in Listing 12.2 and Figure 12.4.
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(url: login_path, scope: :session) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= link_to "(forgot password)", new_password_reset_path %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course (https://www.railstutorial.org/) or to the Learn Enough All Access Subscription (https://www.learnenough.com/all-access).
Verify that the test suite is still GREEN
.
Why does Table 12.1 list the _url
form of the edit
named route instead of the _path
form? Hint: The answer is the same as for the similar account activations exercise (Section 11.1.1).
To create new password resets, we first need to define the data model, which is similar to the one used for account activation (Figure 11.1). Following the pattern set by remember tokens (Chapter 9) and account activation tokens (Chapter 11), password resets will pair a virtual reset token for use in the reset email with a corresponding reset digest for retrieving the user. If we instead stored an unhashed token, an attacker with access to the database could send a reset request to the user’s email address and then use the token and email to visit the corresponding password reset link, thereby gaining control of the account. Using a digest for password resets is thus essential. As an additional security precaution, we’ll also plan to expire the reset link after a couple of hours, which requires recording the time when the reset gets sent. The resulting reset_digest
and reset_sent_at
attributes appear in Figure 12.5.
The migration to add the attributes from Figure 12.5 appears as follows:
$ rails generate migration add_reset_to_users reset_digest:string
> reset_sent_at:datetime
(Recall from Section 11.1.2 that you should type the but not the
>
.) We then migrate as usual:
$ rails db:migrate
To make the view for new password resets, we’ll work in analogy with the previous form for making a new non–Active Record resource, namely, the login form for creating a new session (Listing 8.4), shown again in Listing 12.3 for reference.
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(url: login_path, scope: :session) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= link_to "(forgot password)", new_password_reset_path %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
The new password resets form has a lot in common with Listing 12.3; the most important differences are the use of a different resource and URL in the call to form_with
and the omission of the password attribute. The result appears in Listing 12.4 and Figure 12.6.
<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(url: password_resets_path, scope: :password_reset) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.submit "Submit", class: "btn btn-primary" %>
<% end %>
</div>
</div>
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Why does the form_with
in Listing 12.4 use :password_reset
instead of @password_reset
?
create
ActionUpon submitting the form in Figure 12.6, we need to find the user by email address and update its attributes with the password reset token and sent-at timestamp. We then redirect to the root URL with an informative flash message. As with login (Listing 8.11), in the case of an invalid submission we re-render the new
page with a flash.now
message.2 The results appear in Listing 12.5.
2. Security concerns about revealing the absence of the given email address are commonly misplaced. The reason is that this property is already present in every website that won’t let you sign up with an email address that’s already in use, which is practically all of them. Thus, indicating that a given email address isn’t available for password resets gives potential attackers no additional information.
class PasswordResetsController < ApplicationController
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:info] = "Email sent with password reset instructions"
redirect_to root_url
else
flash.now[:danger] = "Email address not found"
render 'new', status: :unprocessable_entity
end
end
def edit
end
end
The code in the User model parallels the create_activation_digest
method used in the before_create
callback (Listing 11.3), as seen in Listing 12.6.
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
before_save :downcase_email
before_create :create_activation_digest
.
.
.
# Activates an account.
def activate
update_attribute(:activated, true)
update_attribute(:activated_at, Time.zone.now)
end
# Sends activation email.
def send_activation_email
UserMailer.account_activation(self).deliver_now
end
# Sets the password reset attributes.
def create_reset_digest
self.reset_token = User.new_token
update_attribute(:reset_digest, User.digest(reset_token))
update_attribute(:reset_sent_at, Time.zone.now)
end
# Sends password reset email.
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
end
private
# Converts email to all lowercase.
def downcase_email
self.email = email.downcase
end
# Creates and assigns the activation token and digest.
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
end
end
As shown in Figure 12.7, at this point the application’s behavior for invalid email addresses is already working. To get the application working upon submission of a valid email address as well, we need to define a password reset mailer method.
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Submit a valid email address to the form shown in Figure 12.6. What error message do you get?
Confirm at the console that the user in the previous exercise has valid reset_digest
and reset_sent_at
attributes, despite the error. What are the attribute values?
We left Section 12.1 with a nearly working create
action in the Password Resets controller. The only thing missing is the method to deliver valid password reset emails.
If you followed Section 11.1, you already have a default password_reset
method in app/mailers/user_mailer.rb
as a result of the User mailer generation in Listing 11.6. If you skipped Chapter 11, you can just copy the code below (omitting the account_activation
and related methods) and create the missing files as necessary.
In Listing 12.6, we applied the design pattern implemented as a refactoring in Section 11.3.3 by putting the User mailer directly in the model (Listing 12.6):
UserMailer.password_reset(self).deliver_now
The password reset mailer method needed to get this working is nearly identical to the mailer for account activation developed in Section 11.2. We first create a password_reset
method in the user mailer (Listing 12.7), and then define view templates for plain-text email (Listing 12.8) and HTML email (Listing 12.9).
class UserMailer < ApplicationMailer
def account_activation(user)
@user = user
mail to: user.email, subject: "Account activation"
end
def password_reset(user)
@user = user
mail to: user.email, subject: "Password reset"
end
end
To reset your password click the link below:
<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>
This link will expire in two hours.
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<%= link_to "Reset password", edit_password_reset_url(@user.reset_token,
email: @user.email) %>
<p>This link will expire in two hours.</p>
<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
As with account activation emails (Section 11.2), we can preview password reset emails using the Rails email previewer. The code is exactly analogous to Listing 11.18, as shown in Listing 12.10.
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/account_activation
def account_activation
user = User.first
user.activation_token = User.new_token
UserMailer.account_activation(user)
end
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/password_reset
def password_reset
user = User.first
user.reset_token = User.new_token
UserMailer.password_reset(user)
end
end
With the code in Listing 12.10, the HTML and text email previews appear as in Figure 12.8 and Figure 12.9.
With the code in Listing 12.7, Listing 12.8, and Listing 12.9, submission of a valid email address appears as shown in Figure 12.10. The corresponding email appears in the server log and should look something like Listing 12.11.
UserMailer#password_reset: processed outbound mail in 6.0ms
Delivered mail [email protected] (2.8ms)
Date: Fri, 11 Mar 2022 01:30:16 +0000
From: [email protected]
To: [email protected]
Message-ID: <[email protected]>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
boundary="--==_mimepart_5d609328d5404_28872b106582ddf488531";
charset=UTF-8
Content-Transfer-Encoding: 7bit
----==_mimepart_5d609328d5404_28872b106582ddf488531
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: 7bit
To reset your password click the link below:
https://0ebe1dc6d40e4a4bb06e0ca7fe138127.vfs.cloud9.us-east-2.
amazonaws.com/password_resets/cT3mB4pwu7o-hrg6qEDfKg/
edit?email=michael%40michaelhartl.com
This link will expire in two hours.
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
----==_mimepart_5d609328d5404_28872b106582ddf488531
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<a href="https://0ebe1dc6d40e4a4bb06e0ca7fe138127.vfs.cloud9.us-east-2.
amazonaws.com/password_resets/cT3mB4pwu7o-hrg6qEDfKg/
edit?email=michael%40michaelhartl.com">Reset password</a>
<p>This link will expire in two hours.</p>
<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
</body>
</html>
----==_mimepart_5d609328d5404_28872b106582ddf488531--
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Preview the email templates in your browser. What do the Date fields read for your previews?
Submit a valid email address to the new password reset form. What is the content of the generated email in the server log?
At the console, find the user object corresponding to the email address from the previous exercise and verify that it has valid reset_digest
and reset_sent_at
attributes.
In analogy with the account activation mailer method test (Listing 11.20), we’ll write a test of the password reset mailer method, as shown in Listing 12.12. Note: If you’re planning to send email in production (Section 12.4), you should replace “[email protected]” in Listing 12.12 with your actual email address (i.e., the one you used in Listing 11.11).
require "test_helper"
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
user = users(:michael)
user.activation_token = User.new_token
mail = UserMailer.account_activation(user)
assert_equal "Account activation", mail.subject
assert_equal [user.email], mail.to
assert_equal ["[email protected]"], mail.from
assert_match user.name, mail.body.encoded
assert_match user.activation_token, mail.body.encoded
assert_match CGI.escape(user.email), mail.body.encoded
end
test "password_reset" do
user = users(:michael)
user.reset_token = User.new_token
mail = UserMailer.password_reset(user)
assert_equal "Password reset", mail.subject
assert_equal [user.email], mail.to
assert_equal ["[email protected]"], mail.from
assert_match user.reset_token, mail.body.encoded
assert_match CGI.escape(user.email), mail.body.encoded
end
end
At this point, the test suite should be GREEN
:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Run just the mailer tests. Are they GREEN
?
Confirm that the test goes RED
if you remove the second call to CGI.escape
in Listing 12.12.
Now that we have a correctly generated email as in Listing 12.11, we need to write the edit
action in the Password Resets controller that actually resets the user’s password. As in Section 11.3.3, we’ll write a thorough integration test as well.
edit
ActionPassword reset emails such as that shown in Listing 12.11 contain links of the following form:
https://example.com/password_resets/3BdBrXeQZSWqcxHA/edit?email=foo%40bar.com
To get these links to work, we need a form for resetting passwords. The task is similar to updating users via the user edit view (Listing 10.2), but in this case with only password and confirmation fields.
There’s an additional complication, though: We expect to find the user by email address, which means we need its value in both the edit
and update
actions. The email will automatically be available in the edit
action because of its presence in the link above, but after we submit the form its value will be lost. The solution is to use a hidden field to place (but not display) the email on the page, and then submit it along with the rest of the form’s information. The result appears in Listing 12.14.
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(model: @user, url: password_reset_path(params[:id])) do |f| %>
<%= render 'shared/error_messages' %>
<%= hidden_field_tag :email, @user.email %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Update password", class: "btn btn-primary" %>
<% end %>
</div>
</div>
Note that Listing 12.14 uses the form tag helper
hidden_field_tag :email, @user.email
instead of
f.hidden_field :email, @user.email
because the reset link puts the email in params[:email]
, whereas the latter would put it in params[:user][:email]
.
To get the form to render, we need to define an @user
variable in the Password Resets controller’s edit
action. As with account activation (Listing 11.31), this involves finding the user corresponding to the email address in params[:email]
. We then need to verify that the user is valid, i.e., that it exists, is activated, and is authenticated according to the reset token from params[:id]
(using the generalized authenticated?
method defined in Listing 11.26). Because the existence of a valid @user
is needed in both the edit
and update
actions, we’ll put the code to find and validate it in a couple of before filters, as shown in Listing 12.15.
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
.
.
.
def edit
end
private
def get_user
@user = User.find_by(email: params[:email])
end
# Confirms a valid user.
def valid_user
unless (@user && @user.activated? &&
@user.authenticated?(:reset, params[:id]))
redirect_to root_url
end
end
end
In Listing 12.15, compare the use of
authenticated?(:reset, params[:id])
to
authenticated?(:remember, cookies[:remember_token])
in Listing 11.28 and
authenticated?(:activation, params[:id])
in Listing 11.31. Together, these three uses complete the authentication methods shown in Table 11.1.
With the code as above, following the link from Listing 12.11 should render a password reset form. The result of pasting the link from the log (Listing 12.11) appears in Figure 12.11.
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Follow the link in the email from the server log in Section 12.2.1. Does it properly render the form as shown in Figure 12.11?
What happens if you submit the form from the previous exercise?
Unlike the Account Activations edit
method, which simply toggles the user from “inactive” to “active”, the edit
method for Password Resets is a form, which must therefore submit to a corresponding update
action. To define this update
action, we need to consider four cases:
An expired password reset
A failed update due to an invalid password
A failed update (which initially looks “successful”) due to an empty password and confirmation
A successful update
Cases 1, 2, and 4 are fairly straightforward, but Case 3 is non-obvious and is explained in more detail below.
Case 1 applies to both the edit
and update
actions, and so logically belongs in a before filter:
before_action :check_expiration, only: [:edit, :update] # Case 1
This requires defining a private check_expiration
method:
# Checks expiration of reset token.
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
In the check_expiration
method, we’ve deferred the expiration check to the instance method password_reset_expired?
, which is a little tricky and will be defined in a moment.
Listing 12.16 shows the implementation of these filters, together with the update
action that implements Cases 2–4. Case 2 gets handled by a failed update, with the error messages from the shared partial in Listing 12.14 displaying automatically when the edit
form is re-rendered. Case 4 corresponds to a successful change, and the result is similar to a successful login (Listing 8.40).
The only failure case not handled by Case 2 is when the password is empty, which is currently allowed by our User model (Listing 10.13) and so needs to be caught and handled explicitly.3 This is Case 3 above. Our method in this case is to add an error directly to the @user
object’s error messages using errors.add
:
3. We need only handle the case where the password is empty because if the confirmation is empty, the confirmation validation (which is skipped if the password is empty) will catch the problem and supply a relevant error message.
@user.errors.add(:password, :blank)
This arranges to use the default message for blank content when the password is empty.4
4. Alert reader Khaled Teilab has noted that one advantage of using errors.add(:password, :blank)
is that the resulting message is automatically rendered in the correct language when using the rails-i18n
gem.
The result of putting Cases 1–4 together is the update
action shown in Listing 12.16.
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
before_action :check_expiration, only: [:edit, :update] # Case 1
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:info] = "Email sent with password reset instructions"
redirect_to root_url
else
flash.now[:danger] = "Email address not found"
render 'new', status: :unprocessable_entity
end
end
def edit
end
def update
if params[:user][:password].empty? # Case 3
@user.errors.add(:password, "can't be empty")
render 'edit', status: :unprocessable_entity
elsif @user.update(user_params) # Case 4
reset_session
log_in @user
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit', status: :unprocessable_entity # Case 2
end
end
private
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
# Before filters
def get_user
@user = User.find_by(email: params[:email])
end
# Confirms a valid user.
def valid_user
unless (@user && @user.activated? &&
@user.authenticated?(:reset, params[:id]))
redirect_to root_url
end
end
# Checks expiration of reset token.
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
end
Note that we’ve added a user_params
method permitting both the password and password confirmation attributes (Section 7.3.2).
As noted above, the implementation in Listing 12.16 delegates the boolean test for password reset expiration to the User model via the code
@user.password_reset_expired?
To get this to work, we need to define the password_reset_expired?
method. As indicated in the email templates from Section 12.2.1, we’ll consider a password reset to be expired if it was sent more than two hours ago, which we can express in Ruby as follows:
reset_sent_at < 2.hours.ago
This can be confusing if you read <
as “less than”, because then it sounds like “Password reset sent less than two hours ago”, which is the opposite of what we want. In this context, it’s better to read <
as “earlier than”, which gives something like “Password reset sent earlier than two hours ago.” That is what we want, and it leads to the password_reset_expired?
method in Listing 12.17. (For a formal demonstration that the comparison is correct, see the proof in Section 12.6.)
class User < ApplicationRecord
.
.
.
# Returns true if a password reset has expired.
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
With the code in Listing 12.17, the update
action in Listing 12.16 should be working. The results for invalid and valid submissions are shown in Figure 12.12 and Figure 12.13, respectively. (Lacking the patience to wait two hours, we’ll cover the case of reset expiration in a test, which is left as an exercise (Section 12.3.3).)
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Follow the email link from Section 12.2.1 again and submit mismatched passwords to the form. What is the error message?
In the console, find the user belonging to the email link, and retrieve the value of the password_digest
attribute. Now submit valid matching passwords to the form shown in Figure 12.12. Did the submission appear to work? How did it affect the value of password_digest
? Hint: Use user.reload
to retrieve the new value.
In Section 9.3.2, we implemented a session token to guard against session hijacking (Section 9.1.1). One likely scenario for users unfortunate enough to have sessions stolen would be to immediately reset their passwords. As a result, it would be especially nice if password reset automatically expired any such hijacked sessions. In Listing 12.18, FILL_IN
the code needed to accomplish this. Hint: Recall from Listing 9.37 that we reused the remember digest as a session token, and the User model already has a method designed to delete this (Listing 9.11).
class PasswordResetsController < ApplicationController
.
.
.
def update
if params[:user][:password].empty?
@user.errors.add(:password, "can't be empty")
render 'edit', status: :unprocessable_entity
elsif @user.update(user_params)
FILL_IN
reset_session
log_in @user
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit', status: :unprocessable_entity
end
end
.
.
.
end
In this section, we’ll write an integration test covering invalid and valid password reset creation and submission (Listing 12.16). As noted in Section 12.3.2, testing reset expiration is left as an exercise (Section 12.3.3).
We’ll get started by generating a test file for password resets:
$ rails generate integration_test password_resets
invoke test_unit
create test/integration/password_resets_test.rb
The steps to test password resets broadly parallel the test for account activation from Listing 11.34, though there is a difference at the outset: We first visit the “forgot password” form and submit invalid and then valid email addresses, the latter of which creates a password reset token and sends the reset email. We then visit the link from the email and again submit invalid and valid information, verifying the correct behavior in each case. The resulting set of tests, which applies the techniques from Section 11.3.3, is shown in Listing 12.19, and is an excellent exercise in reading code. Note among other things that we use both the fixture user (@user = users :michael
) and the actual reset user (@reset_user = assigns(:user)
) and confirm that the latter has a new reset token (as required by the digest creation in Listing 12.16).
require "test_helper"
class PasswordResets < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
end
end
class ForgotPasswordFormTest < PasswordResets
test "password reset path" do
get new_password_reset_path
assert_template 'password_resets/new'
assert_select 'input[name=?]', 'password_reset[email]'
end
test "reset path with invalid email" do
post password_resets_path, params: { password_reset: { email: "" } }
assert_response :unprocessable_entity
assert_not flash.empty?
assert_template 'password_resets/new'
end
end
class PasswordResetForm < PasswordResets
def setup
super
@user = users(:michael)
post password_resets_path,
params: { password_reset: { email: @user.email } }
@reset_user = assigns(:user)
end
end
class PasswordFormTest < PasswordResetForm
test "reset with valid email" do
assert_not_equal @user.reset_digest, @reset_user.reset_digest
assert_equal 1, ActionMailer::Base.deliveries.size
assert_not flash.empty?
assert_redirected_to root_url
end
test "reset with wrong email" do
get edit_password_reset_path(@reset_user.reset_token, email: "")
assert_redirected_to root_url
end
test "reset with inactive user" do
@reset_user.toggle!(:activated)
get edit_password_reset_path(@reset_user.reset_token,
email: @reset_user.email)
assert_redirected_to root_url
end
test "reset with right email but wrong token" do
get edit_password_reset_path('wrong token', email: @reset_user.email)
assert_redirected_to root_url
end
test "reset with right email and right token" do
get edit_password_reset_path(@reset_user.reset_token,
email: @reset_user.email)
assert_template 'password_resets/edit'
assert_select "input[name=email][type=hidden][value=?]", @reset_user.email
end
end
class PasswordUpdateTest < PasswordResetForm
test "update with invalid password and confirmation" do
patch password_reset_path(@reset_user.reset_token),
params: { email: @reset_user.email,
user: { password: "foobaz",
password_confirmation: "barquux" } }
assert_select 'div#error_explanation'
end
test "update with empty password" do
patch password_reset_path(@reset_user.reset_token),
params: { email: @reset_user.email,
user: { password: "",
password_confirmation: "" } }
assert_select 'div#error_explanation'
end
test "update with valid password and confirmation" do
patch password_reset_path(@reset_user.reset_token),
params: { email: @reset_user.email,
user: { password: "foobaz",
password_confirmation: "foobaz" } }
assert is_logged_in?
assert_not flash.empty?
assert_redirected_to @reset_user
end
end
Most of the ideas in Listing 12.19 have appeared previously in this tutorial; the only really novel element is the test of the input
tag:
assert_select "input[name=email][type=hidden][value=?]", user.email
This makes sure that there is an input
tag with the right name, (hidden) type, and email address:
<input id="email" name="email" type="hidden" value="[email protected]" />
With the code as in Listing 12.19, our test suite should be GREEN
:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
In Listing 12.6, the create_reset_digest
method makes two calls to update_-attribute
, each of which requires a separate database operation. By filling in the template shown in Listing 12.21, replace the two update_attribute
calls with a single call to update_columns
, which hits the database only once. After making the changes, verify that the test suite is still GREEN
. (For convenience, Listing 12.21 includes the results of solving the exercise in Listing 11.40.)
Write an integration test for the expired password reset branch in Listing 12.16 by filling in the template shown in Listing 12.22. (This code introduces response.body
, which returns the full HTML body of the page.) There are many ways to test for the result of an expiration, but the method suggested by Listing 12.22 is to (case-insensitively) check that the response body includes the word “expired”.
Expiring password resets after a couple of hours is a nice security precaution, but there is an even more secure solution for cases where a public computer is used. The reason is that the password reset link remains active for 2 hours and can be used even if logged out. If a user resets their password from a public machine, anyone could press the back button and change the password (and get logged in to the site). To fix this, add the code shown in Listing 12.23 to clear the reset digest on successful password update.5
5. Thanks to reader Tristan Ludowyk for suggesting this feature and for providing both a detailed description and a suggested implementation.
Add a line to Listing 12.19 to test for the clearing of the reset digest in the previous exercise. Hint: Combine assert_nil
(first seen in Listing 9.26) with user.reload
(Listing 11.34) to test the reset_digest
attribute directly.
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
before_save :downcase_email
before_create :create_activation_digest
.
.
.
# Activates an account.
def activate
update_columns(activated: true, activated_at: Time.zone.now)
end
# Sends activation email.
def send_activation_email
UserMailer.account_activation(self).deliver_now
end
# Sets the password reset attributes.
def create_reset_digest
self.reset_token = User.new_token
update_columns(reset_digest: FILL_IN, reset_sent_at: FILL_IN)
end
# Sends password reset email.
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
end
private
# Converts email to all lowercase.
def downcase_email
self.email = email.downcase
end
# Creates and assigns the activation token and digest.
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
end
end
require "test_helper"
class PasswordResets < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:michael)
end
end
.
.
.
class ExpiredToken < PasswordResets
def setup
super
# Create a password-reset token.
post password_resets_path,
params: { password_reset: { email: @user.email } }
@reset_user = assigns(:user)
# Expire the token by hand.
@reset_user.update_attribute(:reset_sent_at, 3.hours.ago)
# Attempt to update the user's password.
patch password_reset_path(@reset_user.reset_token),
params: { email: @reset_user.email,
user: { password: "foobar",
password_confirmation: "foobar" } }
end
end
class ExpiredTokenTest < ExpiredToken
test "should redirect to the password-reset page" do
assert_redirected_to new_password_reset_url
end
test "should include the word 'expired' on the password-reset page" do
follow_redirect!
assert_match /FILL_IN/i, response.body
end
end
class PasswordResetsController < ApplicationController
.
.
.
def update
if params[:user][:password].empty?
@user.errors.add(:password, "can't be empty")
render 'edit', status: :unprocessable_entity
elsif @user.update(user_params)
log_in @user
@user.update_attribute(:reset_digest, nil)
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit', status: :unprocessable_entity
end
end
.
.
.
end
Now that we’ve got password resets working in development, in this section we’ll get them working in production as well. The steps are exactly the same as for account activations, so if you already followed Section 11.4 you can skip right to Listing 12.24. Otherwise, you should follow the instructions in Section 11.4 now and then come back here when you’re done.
At this point, you should merge the topic branch into main
(Listing 12.24).
$ rails test
$ git add -A
$ git commit -m "Add password reset"
$ git checkout main
$ git merge password-reset
Then push up to the remote repository and deploy to Heroku:
$ rails test
$ git push && git push heroku
$ heroku run rails db:migrate
Once the Heroku deployment has finished, you can reset your password by clicking the “(forgot password)” link (Figure 12.4). The result should be a reset email as shown in Figure 12.14. Following the link and making invalid or valid submissions should work as it did in development (Figure 12.12 and Figure 12.13). Likewise, upon successfully changing the password, the user should be redirected to the profile page (Figure 12.15).
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Sign up for a new account in production. Did you get the email?
Click on the link in the activation email to confirm that it works. What is the corresponding entry in the server log? Hint: Run heroku logs
at the command line.
Are you able to successfully update your password?
With the added password resets, our sample application’s sign up, log in, and log out machinery is complete and professional-grade. The rest of the Ruby on Rails Tutorial builds on this foundation to make a site with Twitter-like microposts (Chapter 13) and a status feed of posts from followed users (Chapter 14). In the process, we’ll learn about some of the most powerful features of Rails, including image upload, custom database queries, and advanced data modeling with has_many
and has_many :through
.
Like sessions and account activations, password resets can be modeled as a resource despite not being Active Record objects.
Rails can generate Action Mailer actions and views to send email.
Action Mailer supports both plain-text and HTML mail.
As with ordinary actions and views, instance variables defined in mailer actions are available in mailer views.
Password resets use a generated token to create a unique URL for resetting passwords.
Password resets use a hashed reset digest to securely identify valid reset requests.
Both mailer tests and integration tests are useful for verifying the behavior of the User mailer.
We can send email in production using SendGrid.
We saw in Section 12.3 that the comparison test for determining when a password reset has expired is
reset_sent_at < 2.hours.ago
as seen in Listing 12.17. This looks like it might be read as “reset sent less than two hours ago”, which is the opposite of what we want. In this section, we’ll prove that the above comparison is correct.6
6. This proof is the price you pay for reading a web development tutorial written by a physicist. Just be grateful I couldn’t find a way to work
We start by defining two time intervals. Let Δtr be the time interval since sending the password reset and Δte be the expiration time limit (e.g., two hours). A password reset has expired if the time interval since the reset was sent is greater than the expiration limit:
If we write the time now as tN, the password reset sending time as tr, and the expiration time as te (e.g., two hours ago), then we have
and
Plugging Eq. (12.2) and Eq. (12.3) into (12.1) then gives
which upon multiplying through by −1 yields
Converting (12.4) to code with the value te = 2 hours ago gives the password_-reset_expired?
method shown in Listing 12.17:
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
As noted in Section 12.3, if we read <
as “earlier than” instead of “less than”, this code makes sense as the English sentence “The password reset was sent earlier than two hours ago.”
18.226.34.25