まさ@ブログ書き込み中

まさ@ブログ書き込み中

まさの旅、英語、プログラミング、プライベートについて、色々記録しています。

パスワード再設定【Railsチュートリアル12章】

 

こんばんは。タイトルで工夫することさえしなくなったまさです。

 

僕みたいにパスワードをすぐに忘れる人のために、パスワード再設定のための仕組みを作っていきたいと思います。

 

 

大まかな方針

ぶっちゃけ、本章は軽めでした。

それもそのはず、11章の「アカウントの有効化」とよく似た仕組みだからです。

パスワードの再設定にあたっての大まかな流れをここに書いておきます。

  1. ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける

  2. 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する

  3. 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく

  4. ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)

  5. 認証に成功したら、パスワード変更用のフォームをユーザーに表示する

 

見ての通り、アカウントの有効化とよく似ています。アカウントの有効化では、サインアップすると、有効化ダイジェストを生成しデータベースに保存しておきましたが、それはここでいう1, 2, 3番に関連しています。

 

登録したメールアドレスに有効化トークンを含んだリンクを送り、クリックさせて有効化ダイジェストと比較するのはそのまま4番でやっていることですね。

 

アカウントの有効化と違う点は、パスワードの再設定はパスワード変更用のフォームを表示して、更新まで行うところにあります。

 

 

PasswordResetsリソース

実装の手順も同じ。前回はAccountActivationsリソースをつくりましたが、今回はPasswordResetsリソースをつくりましょう。

 

PasswordResetsコントローラ

パスワード再設定用のコントローラをつくるのですが、今回は再設定用のリンクを送るメールアドレスを入力するビューと、そのリンク先のパスワード再設定用のビューをつくらないといけません。

 

というわけで、newアクションとeditアクションをコントローラー生成の際につくっておきます。

$ rails generate controller PasswordResets new edit --no-test-framework

 

ちなみに、--no-test-frameworkとは「テストを生成しないというオプション」らしいです。

 

ルーティングはこんな感じ。

config/routes.rb

  resources :password_resets,     only: [:new, :create, :edit, :update]

 

この4つのアクションが何をするのかを予め伝えておくと、

  • newアクションは「パスワードを忘れました」的なリンクを踏んだとき、メールアドレス入力フォームを表示させる

  • createアクションはメールアドレスを送信したときにリセットトークンやダイジェストをつくり、それをもとに再設定用メールを送る

  • editアクションはアカウント有効化と同じく、ユーザーに届いたメールのリンクを踏んだとき、ビューを表示させる

  • updateアクションは文字通りユーザーのパスワードを更新する

 

という感じになります。ちなみに、editアクションはこの説明の仕方ではただただビューを表示させるだけのアクションのように見えますが、もう少し大切な役割を果たしています。それは後で説明していきます。

 

 

newアクション

最初に、newアクションから実装していきます。

先ほど述べた通り、これはパスワード設定画面を表示するためのアクションです。

 

まずは、以下のコードを書いてリンクを設定します。

      <%= link_to "(forgot password)", new_password_reset_path %>

 

ここで確認なのですが、resources  :password_resetsと書いたことで以下のような名前つきルートが使える様になったことに注意しましょう。

f:id:masaincebu:20170903205937p:plain

(こんなに表や文字やコードを引用しまくって著作権とか色々大丈夫かなと12章にして思い始めたのは内緒です)

 

よって、上のコードは/password_resets/newへGETリクエストを飛ばす、newアクションにルーティングされるリンクを生成しています。

 

 

createアクション

パスワード再設定用のトークン(リセットトークン)を生成し、ダイジェストにしてユーザーのデータベースに保存するのがこのcreateアクションです。

 

そのため、まずはUserモデルにリセットダイジェストを加えるよう、マイグレーションによって変更しなければなりません。

$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime

 

reset_sent_atはリセットトークンの有効期限を決めるために必要なデータです。

 

さて、おまちかねのパスワード再設定用のcreateアクションのコードは以下の通りです。

app/controllers/password_resets_controller.rb

  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

 

フォームに与える引数のちょっとした違い

まず、params[:password_reset][:email]という表記について説明します。実は、パスワード再設定のためのメールアドレス送信フォームは以下のようになっていたのです。

app/views/password_resets/new.html.erb

<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:password_reset, url: password_resets_path) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.submit "Submit", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

 

なんで以前のようにform_for(@user, url: password_resets_path)とフォームを設定してparams[:user][:email]としないのか?と僕も思いました。実際、その理由の追求はRailsチュートリアルの演習問題としても出されていました。

 

そこでググってみると面白い回答が。

qiita.com

 

Qiitaのこの記事を書いているmochikichi321さんによると、

シンボルを使うとよりシンプルなformタグが生成され、オブジェクトを渡すとそのオブジェクトに寄って良しなに出し分けてくれる。newやeditのviewを準備する時に、同じ_form部分テンプレートを利用した時、同じ書き方で出し分けてくれる。

そうな。

 

というわけで、createアクションはparams[:password_reset][:email]でユーザーを特定するわけですが、そのあとにcreate_reset_digestメソッドとsend_password_reset_emailメソッドがそのユーザーに対して呼び出されています。

 

パスワード再設定用のメソッド

次にパスワード再設定用のこれらのメソッドについて解説していきます。

 

create_reset_digestメソッドはrememberメソッド(永続的セッション)やcreate_activation_digestメソッド(アカウントの有効化)と同じく、ランダムな文字列でトークンをつくり、ダイジェストとしてユーザーに値を保持させます。

app/models/user.rb

  attr_accessor :remember_token, :activation_token, :reset_token
  # パスワード再設定の属性を設定する
  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)
  end

 

send_password_reset_emailメソッドは、文字通りパスワード再設定のメールを送信するためのメソッドです。よく見るとこれはUserMailer(メイラー)のメソッドであることがわかります。

  # パスワード再設定のメールを送信する
  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

 

メイラーメソッドを修正する

さて、Userメイラーにpassword_resetメソッドを追加しましょう。

app/mailers/user_mailer.rb

  def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end

 

上のコードは「引数として与えられたユーザーのメールアドレスにメールを送る」というシンプルなものです。

 

また、パスワード再設定のメールのビューをpassword_resetメソッドによって送られるインスタンス変数(@user)をうまく使って変えてみましょう。

app/views/user_mailer/password_reset.text.erb

To reset your password click the link below:

<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>

This link will expire in two hours.

If you did not request your password to be reset, please ignore this email and
your password will stay as it is.

 

app/views/user_mailer/password_reset.html.erb

<h1>Password reset</h1>

<p>To reset your password click the link below:</p>

<%= link_to "Reset password", edit_password_reset_url(@user.reset_token,
                                                      email: @user.email) %>

<p>This link will expire in two hours.</p>

<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>

 

これでcreateアクションに関する大まかな実装の紹介は終わりです。

 

 

editアクション

editアクションは前章と同じく、トークンとメールアドレスを含んだリンクをクリックした際に実行されるアクションです。

 

ちなみに、メールで送られてくるリンクとは以下のようなものです。リセットトークンとemailのクエリパラメータがあることに注意してください。

 

「editアクションにルーティングされてるから、パスワード再設定フォーム(edit.html.erb)を表示すればいいだけでしょ 」と僕は思っていたのですが、ここにも面白いポイントがありました

 

隠しフィールド

それはeditアクションとupdateアクションどちらでもユーザーを検索する必要があるということです。editアクションでユーザーを検索するのは例のリンクに含まれているメールアドレスを使えば簡単ですが、updateアクションではパスワード再設定フォームによってパスワードしか送信されないので、ユーザーを検索できなくなってしまいます。

 

そこで隠しフィールドという手を本章では使っています。具体的には、パスワード再設定フォームの中に以下のようなコード(ハイライトされた箇所)を書きます。

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>

 

これでユーザーには見せずに、メールアドレスの値を送信することができます。

 

また、僕が「なるほど」と感心した話を引用します。ハイライトされたhidden_field_tag :email, @user.emailについてです。

 

hidden_field_tag :email, @user.email

これまでは次のようなコードを書いていましたが、今回は書き方が異なっています。


f.hidden_field :email, @user.email

これは再設定用のリンクをクリックすると、前者 (hidden_field_tag) ではメールアドレスがparams[:email]に保存されますが、後者ではparams[:user][:email] に保存されてしまうからです。

 

f.hidden_field :email, @user.emailだとparams[:user][:email]に保存されてしまう理由として、form_forヘルパーメソッドは@userを引数として f というブロック変数を使っていることが関係してくると僕は思っています。つまり f.hogeは@userの属性として扱われるということです。

 

また、わざわざこのように書き直す理由は先ほどのformタグの引数の例と同じで、こちらの方がよりシンプルにHTMLが生成されるからだと思います。

 

editアクション周りを整える

僕は本記事の冒頭に「editアクションはただただビューを表示させるだけに見えるかもしれない」と書きましたが、このeditアクションの意義は関連するメソッドやbeforeフィルタにあります。具体的に見ていきましょう。

 

先ほど説明したパスワード再設定用のビューを表示するには、ユーザーを先に取得しておく必要があります。また、パスワードの再設定画面はアクセスしているのが正しいユーザーなのか厳重にチェックしなければなりません。

 

それが、以下のget_userメソッドとvalid_userメソッドです。

app/controllers/password_resets_controller.rb

    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

valid_userメソッドでは(get_userで取得された)@user(ユーザー)が

  1. そもそも存在するか
  2. (存在していても)有効化されているか
  3. (存在し、有効化されていても)リセットトークンによる認証が成功するか

 を見ています。

 

この二つのメソッドをbeforeフィルタを使って(beforeアクションとして)設定します。

  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]

 

これで本当に正しいユーザーがパスワード再設定フォームを開き、メールアドレスと更新すべきパスワードをupdateアクションに送ることができるようになりました。

 

 

updateアクション

パスワードの更新にあたって、次の4つのケースを想定する必要があると本章では言われています。

 

  1. パスワード再設定の有効期限が切れていないか
  2. 無効なパスワードであれば失敗させる (失敗した理由も表示する)
  3. 新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
  4. 新しいパスワードが正しければ、更新する

 

2番と4番に関しては、今まで実装してきたように、editページを再描画(render)させてエラーメッセージを表示させたり、パスワード属性の値を更新させる(update_attribute)させればよさそうです。問題は1番と3番ですね。

 

パスワード再設定の有効期限が切れていないか(1番の問題)

これはbeforeアクションで有効期限をチェックするメソッドを呼び出せばいいですね。

before_action :check_expiration, only: [:edit, :update]    # (1) への対応案
app/controllers/password_resets_controller.rb
    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end

 

パスワードが空文字列だったらどうするか(3番の問題)

ここが少しややこしいところ。本章では

以前Userモデルを作っていたときに、パスワードが空でも良い (リスト 10.13のallow_nil) という実装をしたからです。したがって、このケースについては明示的にキャッチするコードを追加する必要があります。

と説明されています。

 

ユーザー情報を更新する際(users#edit)、パスワードの欄は空白で表示されますが、更新したくなければそのまま何も入力せずに更新ボタンが押せたのは、このallow_nilのおかげでした。

 

ちなみにコードはこんな感じ。

validates :password, presence: true, length: { minimum: 6 }, allow_nil: true

 

よって、今回はパスワードが空文字の場合は特別にエラーメッセージをこちらから追加してパスワード再設定画面を再描画するという方法を取ります。エラーメッセージを追加するコードは以下の通り。

@user.errors.add(:password, :blank)

 

よって、updateアクションの中身は以下の通りになります。

app/controllers/password_resets_controller.rb

  before_action :check_expiration, only: [:edit, :update]    # (1) への対応
  def update
    if params[:user][:password].empty?                  # (3) への対応
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)          # (4) への対応
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'                                     # (2) への対応
    end
  end
    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end
    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end

 

悪意のあるユーザーがパスワード以外の情報を更新させないために、受け取るparamsの値を制限するuser_paramsメソッドが定義されていることにも注目しましょう。

 

また、check_expirationメソッドの中にあるpassword_reset_expired?メソッドは以下の通りです。

app/models/user.rb

  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

 

この式の意味はパスワード再設定用のメールが送信されてからの時間が(現在とくらべて)2時間以内かどうかを表しています。

 

14:00にパスワードの再設定用のメールが送信されていれば、現在時刻が(同日の)16:30であればOUTですよね。それをこの式では

 

14:00(メールが送信された時刻) < 14:30(現在時刻の2時間前)

 

とし、これが成り立ってしまうとtrue(再設定の期限が切れている)を返すわけです。

 

僕はそんなこと考えませんでしたが、本章では丁寧に

上の < 記号を「〜より少ない」と読んでしまうと、「パスワード再設定メール送信時から経過した時間が、2時間より少ない場合」となってしまい、困惑してしまうので注意してください。

と解説してくれています。

 

本章ではしっかり証明までしてくれています。すごい親切。

https://railstutorial.jp/chapters/password_reset?version=5.0#sec-expiration_proof

 

 

ここらへんで12章のまとめは終わりです。また13章を終え次第まとめていきます!

ではまた。