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.
This commit is contained in:
songtianlun 2025-01-08 11:44:42 +08:00
parent 8bb7615cb1
commit 32ec61fe00
4 changed files with 151 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -1,2 +1,21 @@
<h1>PasswordResets#edit</h1>
<p>Find me in app/views/password_resets/edit.html.erb</p>
<% 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]),
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 %>
</div>
</div>

View File

@ -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