Reset Password in Rails from Scratch

Reset Password in Rails from Scratch

As a continuation of Rails Authentication from Scratch, let's add a password reset feature to our application.

Generating the Reset Password Token

The general flow of a password reset is as follows:

  1. User clicks "Forgot Password" link
  2. User enters email address
  3. User receives email with a link to reset password
  4. User clicks link and is taken to a form to enter a new password
  5. User enters new password.
  6. User submits form and password is updated.

That's the happy path. To make this happen we need to create unique secure links that are sent to the email address they entered that can load a form keyed to the user that generated the link. We'll use the SecureRandom library to generate a random token that we can use as a unique identifier for the password reset.

The first step is to add a password_reset_token column to our users table. We can do this with a migration:

rails g migration add_password_reset_token_to_users password_reset_token:string password_reset_token_expires_at:datetime

With the password_reset_token_expires_at, you can add a layer of security expiring the token after a certain amount of time.

When the user requests to reset their password, we'll populate these fields in the model.

PasswordReset

Let's build a virtual model, basically a Ruby class to encapsulate this functionality.

app/models/password_reset.rb

class PasswordReset
  include ActiveModel::Model

  attr_accessor :user, :email

  def save
    @user = User.find_by(email: email)
    if @user
      @user.password_reset_token = SecureRandom.urlsafe_base64
      @user.password_reset_token_expires_at = 24.hours.from_now
      @user.save
      UserMailer.password_reset(@user).deliver_later
    end
  end

  def self.find_by_valid_token(token)
    User.where("password_reset_token = ? AND password_reset_token_expires_at > ?", token, Time.now).first
  end
end

As an ActiveModel we can use this class with form_for. The class basically does two things, it will generate the required token and expiration date when we make it and it will handle finding a user by a valid token. Using a new model that isn't backed by a database table is fine. By doing this we can keep our User class free from this functionality but still have a nice object for our forms and our controllers.

PasswordResetsController

We'll use a PasswordResetsController for all the functionality with:

  • GET /password_resets/new as a route to a form to request a reset password link.
  • POST /password_resets as a route to create the password reset and send out the email.
  • GET /password_resets/:id/edit where :id is the valid password reset token and present the user with a form to reset their password.
  • PATCH /password_resets/:id/ to set the new password if the password reset token is valid.

We can create these routes RESTfully with resources :password_resets, only: [:new, :create, :edit, :update] in config/routes.rb.

Requesting a Password Reset

Let's build the form to request a password reset. Because we have the PasswordReset model, we can create an instance to wrap a form around.

app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  def new
    @password_reset = PasswordReset.new
  end
end

app/views/password_resets/new.html.erb

<%= render_form_for(@password_reset) do |f| %>
  <div class="grid gap-2">
    <div class="grid gap-1">
      <%= f.label :email, class: "sr-only" %>
      <%= f.email_field :email, placeholder: "name@example.com",
                                autocomplete: :email,
                                autocorrect: "email",
                                autocapitalize: "none" %>
    </div>
    <%= f.submit "Reset Password" %>
  </div>
<% end %>

That allows for a user to enter their email and will submit a POST /password_resets. And remember, render_form_for is just a wrapper for shadcn-ui around form_for, so it behaves the same.

When this form is submitted, the create#passwordresets will work like:

app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  def new
    @password_reset = PasswordReset.new
  end

  def create
    @password_reset = PasswordReset.new(password_reset_params)
    @password_reset.save

    flash[:notice] = "A link to reset your password has been sent to your email."
    redirect_to root_url
  end
end

PasswordReset#save will:

app/models/password_reset.rb

class PasswordReset
  include ActiveModel::Model

  attr_accessor :user, :email

  def save
    @user = User.find_by(email: email)
    if @user
      @user.password_reset_token = SecureRandom.urlsafe_base64
      @user.password_reset_token_expires_at = 24.hours.from_now
      @user.save
      UserMailer.password_reset(@user).deliver_later
    end
  end
end

So it will set the password_reset_token to a secure random string that expires in 24 hours. It will also send out the email. Let's generate that now:

rails g mailer user

app/mailers/user_mailer.rb

class UserMailer < ApplicationMailer
  def password_reset(user)
    @user = user
    mail(to: @user.email, subject: "Reset Your Password")
  end
end

And the mailer template:

app/views/user_mailer/password_reset.html.erb

<p>Hello,</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_reset_url(@user.password_reset_token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>

The mailer template will use the password_reset_token to create a URL edit_password_reset_url(@user.password_reset_token). We'll use that URL to validate and find the password reset in PasswordResetsController#edit and PasswordResetsController#update.

Together all this creates the request password reset flow.

Mailers in Development

I find the best way to test mailers like this in development is to use the letter opener gem. Once that is added to your Gemfile, you can set:

config.action_mailer.perform_caching = false
config.action_mailer.raise_delivery_errors = true
config.action_mailer.perform_deliveries = true
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
config.action_mailer.delivery_method = :letter_opener

In config/development.rb. Then when you test this flow in development, the email with the link will open in a new browser tab and you can click it and continue the flow as described below.

Resetting the Password

The first step is to build GET /password_resets/:id/edit. We'll use PasswordReset.find_by_valid_token that we created to find the user for the valid password reset token.

app/models/password_reset.rb

class PasswordReset
  # Rest of Model...

  def self.find_by_valid_token(token)
    User.where("password_reset_token = ? AND password_reset_token_expires_at > ?", token, Time.now).first
  end
end

The find_by_valid_token uses SQL to find the matching token and ensure that it's valid given the current time. Let's implement this in our PasswordResetsController#edit.

app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  # Rest of Controller...

  def edit
    @user = PasswordReset.find_by_valid_token(params[:id])
    if @user
    else
      flash[:alert] = "Your password reset link is not valid."
      redirect_to new_password_reset_path
    end
  end

If we can find a user by the token in the URL, we'll render the edit form, which will present them with the ability to change their password, otherwise, we'll redirect them to make a new request for a password reset link. Here's what the edit form looks like:

app/views/password_resets/edit.html.erb

<%= render_form_for(@user, url: password_reset_path, method: :patch) do |f| %>
  <div class="grid gap-2">
    <div class="grid gap-1">
      <div class="grid gap-1">
        <%= f.label :password, class: "sr-only" %>
        <%= f.password_field :password, placeholder: "Your password...",
                                      autocomplete: "current-password" %>
      </div>
      <div class="grid gap-1">
        <%= f.label :password, class: "sr-only" %>
        <%= f.password_field :password_confirmation, placeholder: "Confirm your password...",
                                      autocomplete: "current-password" %>
      </div>
      <%= f.submit "Reset Password" %>
    </div>
  </div>
<% end %>

The URL of the form will be a PATCH request password_reset_path creating a submission to PATCH /password_resets/:id/ which will route to PasswordResetsController#update. In that action we'll find the user the same way we did in edit and accept the fields from the form to update the password if a user was found. The entire controller now looks like:

app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  def new
    @password_reset = PasswordReset.new
  end

  def create
    @password_reset = PasswordReset.new(password_reset_params)
    @password_reset.save

    flash[:notice] = "A link to reset your password has been sent to your email."
    redirect_to root_url
  end

  def edit
    @user = PasswordReset.find_by_valid_token(params[:id])
    if @user
    else
      flash[:alert] = "Your password reset link is not valid."
      redirect_to new_password_reset_path
    end
  end

  def update
    @user = PasswordReset.find_by_valid_token(params[:id])
    if @user
      if @user.update(user_params)
        flash[:notice] = "Your password has been updated."
        redirect_to root_url
      else
        flash.now[:alert] = "There was an error updating your password."
        render :edit, status: :unprocessable_entity
      end
    else
      flash[:error] = "Your password reset link is not valid."
      redirect_to new_password_reset_path
    end
  end

  private

  def password_reset_params
    params.require(:password_reset).permit(:email)
  end

  def user_params
    params.require(:user).permit(:password, :password_confirmation)
  end
end

Conclusion

The key is managing the password_reset_token via SecureRandom. The rest is just patterns on top of Rails controllers, views, and mailers. Once again, it's not that hard to build a secure password reset yourself, it just takes a bit of wiring up.

In the next post, we'll build the ability to login via a magic link which is a pretty similar implementation to this if you could guess.

Did you find this article valuable?

Support Avi Flombaum by becoming a sponsor. Any amount is appreciated!