From 32ec61fe005ce79605da67a866d3fb3f6ce47365 Mon Sep 17 00:00:00 2001 From: songtianlun Date: Wed, 8 Jan 2025 11:44:42 +0800 Subject: [PATCH] feat: add password reset functionality - Implement password reset request and form - Add user validation and expiration checks - Create integration tests for password reset process This commit introduces a complete password reset feature, allowing users to reset their passwords securely. It includes necessary validations to ensure the user is valid and the reset token has not expired. Additionally, integration tests have been added to verify the functionality and edge cases, enhancing overall application security and user experience. --- app/controllers/password_resets_controller.rb | 45 +++++++++++ app/models/user.rb | 8 +- app/views/password_resets/edit.html.erb | 23 +++++- test/integration/password_resets_test.rb | 79 +++++++++++++++++++ 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 test/integration/password_resets_test.rb diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb index 0acd046..70e5487 100644 --- a/app/controllers/password_resets_controller.rb +++ b/app/controllers/password_resets_controller.rb @@ -1,4 +1,9 @@ class PasswordResetsController < ApplicationController + before_action :get_user, only: [:edit, :update] + before_action :valid_user, only: [:edit, :update] + before_action :check_expiration, only: [:edit, :update] + + include SessionsHelper def new end @@ -17,4 +22,44 @@ class PasswordResetsController < ApplicationController def edit end + + 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) + forget(@user) + reset_session + @user.update_attribute(:reset_digest, nil) + log_in @user + flash[:success] = "Password has been reset" + redirect_to @user + else + render 'edit', status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:password, :password_confirmation) + end + + def get_user + @user = User.find_by(email: params[:email]) + end + + def valid_user + unless @user && @user.activated? && + @user.authenticated?(:reset, params[:id]) + redirect_to root_url + end + end + + def check_expiration + if @user.password_reset_expired? + flash[:danger] = "Password reset has expired" + redirect_to new_password_reset_url + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 1875cf8..591b6e4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,14 +69,18 @@ class User < ApplicationRecord 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) + update_columns(reset_digest: User.digest(reset_token), + reset_send_at: Time.zone.now) end def send_password_reset_email UserMailer.password_reset(self).deliver_now end + def password_reset_expired? + reset_send_at < 2.hours.ago + end + private def downcase_email diff --git a/app/views/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb index 2d1800e..3887a78 100644 --- a/app/views/password_resets/edit.html.erb +++ b/app/views/password_resets/edit.html.erb @@ -1,2 +1,21 @@ -

PasswordResets#edit

-

Find me in app/views/password_resets/edit.html.erb

+<% provide(:title, "Reset password") %> +

Reset password

+ +
+
+ <%= form_with(model: @user, url: password_reset_path(params[:id]), + local: true) 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 %> +
+
diff --git a/test/integration/password_resets_test.rb b/test/integration/password_resets_test.rb new file mode 100644 index 0000000..4260d69 --- /dev/null +++ b/test/integration/password_resets_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class PasswordResetsTest < ActionDispatch::IntegrationTest + def setup + ActionMailer::Base.deliveries.clear + @user = users(:michael) + end + + test "password reset" do + get new_password_reset_path + assert_template "password_resets/new" + assert_select "input[name=?]", "password_reset[email]" + # email is invalid + post password_resets_path, params: { password_reset: { email: "" } } + assert_not flash.empty? + assert_template "password_resets/new" + # email is valid + post password_resets_path, + params: { password_reset: { email: @user.email } } + assert_not_equal @user.reset_digest, @user.reload.reset_digest + assert_equal 1, ActionMailer::Base.deliveries.size + assert_not flash.empty? + assert_redirected_to root_url + # password reset form + user = assigns(:user) + # email is error + get edit_password_reset_path(user.reset_token, email: "") + assert_redirected_to root_url + # user is inactivated + user.toggle!(:activated) + get edit_password_reset_path(user.reset_token, email: user.email) + assert_redirected_to root_url + user.toggle!(:activated) + # email is right, token is wrong + get edit_password_reset_path("wrong token", email: user.email) + assert_redirected_to root_url + # email is right, token is right + get edit_password_reset_path(user.reset_token, email: user.email) + assert_template "password_resets/edit" + assert_select "input[name=email][type=hidden][value=?]", user.email + # password is not patch + patch password_reset_path(user.reset_token), + params: { email: user.email, + user: { password: "foobaz", + password_confirmation: "barquux" } } + assert_select "div#error_explanation" + # password is empty + patch password_reset_path(user.reset_token), + params: { email: user.email, + user: { password: "", + password_confirmation: "" } } + assert_select "div#error_explanation" + # password and password_confirmation is valid + patch password_reset_path(user.reset_token), + params: { email: user.email, + user: { password: "foobaz", + password_confirmation: "foobaz" } } + assert is_logged_in? + assert_not flash.empty? + assert_redirected_to user + assert_nil user.reload.reset_digest + end + + test "expired token" do + get new_password_reset_path + post password_resets_path, + params: { password_reset: { email: @user.email } } + + @user = assigns(:user) + @user.update_attribute(:reset_send_at, 3.hour.ago) + patch password_reset_path(@user.reset_token), + params: { email: @user.email, + user: { password: "foobar", + password_confirmation: "foobar" } } + assert_response :redirect + follow_redirect! + assert_match "expired", response.body + end +end