Lesson n°11

Edit password

What we will build

The last feature I want to talk about in this course is the ability for a user to update their password while logged in. It may seem like an easy feature, but there are a few important things that we must not miss to make it right.

First, it's important to ask for the current password before allowing the user to update it. This is a security measure to ensure that the user is the one who is actually changing the password. If we don't ask for the current password, anyone who has access to the user's account can change the password without the user's knowledge.

Remember our user who forgot to log out of the public airport computer? Well, if we don't ask for the current password, anyone who has access to the computer can change the password and lock the user out of their account. This is a security threat.

Here is what the edit password page will look like:

Edit password page

If the user submits the form with a valid password, password confirmation, and current password, they will be redirected to the dashboard page with a success message:

Home page with a success message

Implementing the update password feature

With that in mind, let's start implementing our edit password feature. As usual, we'll start with the route:

# config/routes.rb

Rails.application.routes.draw do
  # All the previous routes

  resource :password, only: %i[edit update]
end

Then we'll create the view so that the user can update their password:

<%# app/views/passwords/edit.html.erb %>

<h1>Edit password</h1>

<%= form_with scope: :password, url: password_path, method: :patch do |f| %>
  <div>
    <%= f.label :password, "New password" %>
    <%= f.password_field :password %>
  </div>

  <div>
    <%= f.label :password_confirmation, "Password confirmation" %>
    <%= f.password_field :password_confirmation %>
  </div>

  <div>
    <%= f.label :password_challenge, "Current password" %>
    <%= f.password_field :password_challenge %>
  </div>

  <%= f.submit "Update password" %>
<% end %>

As we can see, the previous form contains three inputs:

  • The first one, :password, is for the new password.
  • The second one, :password_confirmation, is to confirm that the user didn't make a typo when typing their password. It's optional, but I just want to show you that it's possible to have it out of the box with Rails thanks to has_secure_password.
  • The last one, :password_challenge, is for the user's current password. As discussed in the introduction, this field is to ensure that the user is the one who is actually changing the password.

Let's also add a link to the edit password page in the navbar:

<%# app/views/layouts/application.html.erb %>

<!DOCTYPE html>
<html>
  <head>
    <%# All the previous code %>
  </head>
  <body>
    <header>
      <nav>
        <ul>
          <li><%= link_to "Home", root_path %></li>
          <li><%= link_to "Dashboard", dashboard_path %></li>
          <li><%= link_to "Edit password", edit_password_path %></li>
          <li><%= link_to "Sign in", new_session_path %></li>
          <li><%= link_to "Sign up", new_registration_path %></li>
          <li><%= button_to "Sign out", session_path, method: :delete %></li>
        </ul>
      </nav>
    </header>
    <main>
      <%= notice %>

      <%= yield %>
    </main>
  </body>
</html>

Finally, let's work on the controller:

# app/controllers/passwords_controller.rb

class PasswordsController < ApplicationController
  before_action :require_authentication

  def edit
  end

  def update
    if Current.user.update(password_params)
      redirect_to dashboard_path, notice: "Password updated"
    else
      flash.now[:notice] = Current.user.errors.full_messages.to_sentence
      render :edit, status: :unprocessable_entity
    end
  end

  private

  # Not the final implementation. There is a security issue here
  def password_params
    params
      .require(:password)
      .permit(:password, :password_confirmation, :password_challenge)
  end
end

Once again, the #edit action doesn't do anything in particular. It's just here so that we can display the password edit form.

The #update action takes the parameters submitted by the form and tries to update the user's password. If the password is updated successfully, the user is redirected to the dashboard with a success message. Otherwise, the user is redirected back to the edit password page with an error message.

Note that we require the user to be authenticated to perform the actions in this controller.

However, there is a security vulnerability in this implementation. If we submit a request without a password challenge, it is possible to update the user's password without knowing the current password.

One way to do that is to navigate to the edit password page, open the browser's developer tools in the "Elements" tab, and manually remove the password_challenge input from the form. Fill both the password and password_confirmation inputs with "newpassword". When you submit the form, it will reach the server with the following parameters:

{
  "password": {
    "password": "newpassword",
    "password_confirmation": "newpassword"
  }
}

As we can see, the password_challenge parameter is missing. This means that the user's password will be updated without checking the current password, which is a big security vulnerability.

Let's fix this by adding a default value to the password_challenge parameter in the backend:

# app/controllers/passwords_controller.rb

class PasswordsController < ApplicationController
  def password_params
    params
      .require(:password)
      .permit(:password, :password_confirmation, :password_challenge)
      .with_defaults(password_challenge: "") # This line is important!
  end
end

This fixes our problem. If we try the experiment again and remove the password_challenge input from the form and submit the form, the user's password will not be updated because the password_challenge parameter will be an empty string by default and so it won't match the user's current password.

Of course, we are going to write some automated tests to make sure this behavior is working as expected.

Testing the edit password feature

Let's start by testing the #edit action to make sure that the edit password page is displayed correctly:

# test/controllers/passwords_controller_test.rb

require "test_helper"

class PasswordsControllerTest < ActionDispatch::IntegrationTest
  setup do
    sign_in users(:alex)
  end

  test "#edit" do
    get edit_password_path
    assert_response :success
  end
end

As this controller requires authentication, we need to sign in a user before testing the #edit action. As we need to be authenticated in all test cases, we can use the setup method to sign in the user before each test case as the content of the setup block will run before each test case.

Now let's test the #update action. We'll start with the happy path:

# test/controllers/passwords_controller_test.rb

require "test_helper"

class PasswordsControllerTest < ActionDispatch::IntegrationTest
  # All the previous code

  test "#update with valid params updates the user's password" do
    patch password_path, params: {
      password: {
        password: "newpassword",
        password_confirmation: "newpassword",
        password_challenge: "password"
      }
    }
    assert_redirected_to dashboard_path
    assert users(:alex).reload.authenticate("newpassword")
  end
end

In this test, we check that when submitting valid parameters for the new password and the current password, the user is redirected to the dashboard and the user's password is successfully updated.

Let's add a test case for when the password challenge parameter is missing:

# test/controllers/passwords_controller_test.rb

require "test_helper"

class PasswordsControllerTest < ActionDispatch::IntegrationTest
  # All the previous code

  test "#update with nil password challenge does not update the user's password" do
    patch password_path, params: {
      password: { password: "newpassword", password_confirmation: "newpassword" }
    }
    assert_response :unprocessable_entity
    assert_not users(:alex).reload.authenticate("newpassword")
  end
end

This time, we check that when the password challenge parameter is missing, the user's password is not updated, which is exactly what we want.