Resetting the password

為了讓這個連結起作用:

http://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=foo%40bar.com

我們要建立一個重設密碼的表單。這個表單的目的和編輯個人資料的表單很像,差別只在於欄位只有密碼和密碼確認。

我們預期透過 email 尋找使用者,也就是說在 editupdate action 中需要有 email 的位址。在 edit action 中可以輕易取得 email,因為連結裡就有,不過提交表單後,email 就會消失了。為了解決這個問題,可以使用 hidden field,把這個欄位的值設成 email(不會顯示在頁面),和表單中的其他資料一起提交給 update action,如以下程式碼所示:

app/views/password_resets/edit.html.erb

<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) 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>

這裡我們使用了 form tag helper:

hidden_field_tag :email, @user.email

取代:

f.hidden_field :email, @user.email

因為在重設密碼的連結中,email 位址在 params[:email] 中,如果使用後者,就會把 email 放在 params[:user][:email] 裡。

為了正確渲染表單,我們需要在 Password Resets controller 的 edit action 定義一個 @user 變數。和啟動帳號一樣,我們要找到 params[:email] 中 email 相對應的使用者,然後確認這名使用者已經被啟動,接著使用之前定義的 authenticated? 方法驗證 params[:id] 的 reset token。

因為在 editupdate action 中都要使用 @user,所以要把查詢使用者和驗證 reset token 的程式碼放進 before filters,如下所示:

app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]
  .
  .
  .
  def edit
  end

  private

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # Confirms a valid user.
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end
end

點擊重設密碼的連結:

https://localhost:3000/password_resets/TMTshEIj-gAvp9_nos8Log/edit?email=example%40railstutorial.org

就會看到以下畫面:

對應 edit action 的 update action,需要考慮以下四種情形:

  1. 過期的重設密碼
  2. 密碼重設成功
  3. 因為無效的密碼導致重設失敗
  4. 密碼和密碼確認的值為空值導致的重設失敗(看起來會像成功)

第一種情況,會發生在 editupdate action,所以邏輯上會使用 before filter 處理。

第二、三種情況,使用 if 述句來處理,因為這個表單會修改 Active Record 的模型物件,所以可以使用共用的 partial 來顯示錯誤訊息。

最後一種情況,因為目前 User model 允許密碼出現空值,所以要特別處理,我們要直接在 @user 物件上,加入錯誤訊息:

@user.errors.add(:password, "can't be empty")

以下是完整程式碼:

app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  before_action :get_user,         only: [:edit, :update]
  before_action :valid_user,       only: [:edit, :update]
  before_action :check_expiration, only: [:edit, :update]

  def new
  end

  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @user.create_reset_digest
      @user.send_password_reset_email
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

  def edit
  end

  def update
    if params[:user][:password].empty?
      @user.errors.add(:password, "can't be empty")
      render 'edit'
    elsif @user.update_attributes(user_params)
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'
    end
  end

  private

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

    # Before filters

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # Confirms a valid user.
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end

    # Checks expiration of reset token.
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
end

上面使用 @user.password_reset_expired? 來判斷重設是否過期,所以我們要再定義一個 password_reset_expired? 方法,根據之前的重設郵件內容,我們是限定兩小時內進行重設,也就是超過兩小時,那個連結變失效,可以透過使用 Ruby 來實作:

reset_sent_at < 2.hours.ago

如果把 > 讀成「less than」,聽起來會像是「密碼重設郵件發送小於兩小時」,但實際上應該讀成「earlier than」,也就是「密碼重設郵件發送已經超過兩小時」,這才是我們要表達的意思。

定義 password_reset_expired? 方法如下:

app/models/user.rb

class User < ActiveRecord::Base
  .
  .
  .
  # Returns true if a password reset has expired.
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

現在 update action 可以運作了,以下分別是密碼重設失敗和成功的畫面: