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:
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:
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 tohas_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.