Rails

Rails Tutorial 第12章 簡易まとめ

More than 1 year has passed since last update.

12章 パスワードの再設定

パスワードを忘れた時のパスワードの再設定に取り組みます。

手順は
1、サンプルアプリケーションのログインフォームに「forgot password」リンクを追加します

2、この「forgot password」リンクをクリックするとフォームが表示され、そこにメールアドレスを入力、パスワード再設定用のリンクが記載されたメールを送信

3、この再設定用のリンクをクリックすると、ユーザーのパスワードを再設定用フォームを表示

4、有効な情報な場合更新

アカウント有効化の際と似ていて、PasswordResetsリソースを作成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となります。

12.1 PasswordResetsリソース

前章と同様に、今回も新たなモデルは作らずに、代わりに必要なデータ (再設定用のダイジェストなど) をUserモデルに追加していく形で進めていきましょう。

有効化のときはeditアクションだけを取り扱いましたが、今回はパスワードを再設定するフォームが必要なので、ビューを描画するためのnewアクションとeditアクションが必要になります。また、それぞれのアクションに対応する作成用/更新用のアクションも最終的なRESTfulなルーティングには必要になります。

$ git checkout -b password-reset

12.1.1 PasswordResetsコントローラ

前章同様コントローラ生成から。newアクションとeditアクションも一緒に生成

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

上のコマンドではテストを生成しないというオプションを指定していることにご注目ください。これはコントローラの単体テストをする代わりに、今回は統合テストでカバーしていくから。

ということでそれぞれのアクションに対応する作成用/更新用のアクションをふくめたnew、create、edit、updateのルーティングを設定

config/routes.rb
 Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
end

名前付きルート
image.png

パスワード再設定へのリンクをログイン画面に追加

app/views/sessions/new.html.erb
 <% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= link_to "(forgot password)", new_password_reset_path %><--
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

12.1.2 新しいパスワードの設定

今まで同様重要な値を取り扱うのでパスワードの再設定では必ずダイジェストを使うようにしてください。
セキュリティ上の注意点はもう1つあります。それは再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。
そのために、再設定メールの送信時刻も記録する必要があります。以上の背景に基づいて、reset_digest属性とreset_sent_at属性をUserモデルに追加

$ rails generate migration add_reset_to_users reset_digest:string  reset_sent_at:datetime

$ rails db:migrate

この時点でのUserモデルのカラムは以下のとおりになる
image.png

再設定用のビューを作成します。
共通部分が多いログインページのコードを参考に以下の様にする

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で扱うリソースとURLが異なっている点
なぜ@password_resetではなく:password_resetを使っているのでしょうか?→(参考:https://ja.stackoverflow.com/questions/18099/rails%E3%81%AEform-for%E3%81%AB%E3%82%B7%E3%83%B3%E3%83%9C%E3%83%AB%E3%82%92%E4%B8%8E%E3%81%88%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AF%E3%81%A9%E3%81%AE%E3%82%88%E3%81%86%E3%81%AA%E3%81%A8%E3%81%8D%E3%81%8B%EF%BC%89)

12.1.3 createアクションでパスワード再設定

newフォームから有効なメールアドレスの送信を行なった後、
1、メールアドレスをキーとしてユーザーをデータベースから見つけ、
2、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要があります。
3、それに続いてルートURLにリダイレクトし、
4、フラッシュメッセージをユーザーに表示します。

elseで
送信が無効の場合は、ログインと同様にnewページを出力してflash.nowメッセージを表示します

app/controllers/password_resets_controller.rb
 class PasswordResetsController < ApplicationController

  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
end
app/models/user.rb
 class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_attribute(:activated,    true)
    update_attribute(:activated_at, Time.zone.now)
  end

  # 有効化用のメールを送信する
  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  # パスワード再設定の属性を設定する
  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に変更しよう
  end

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

  private

    # メールアドレスをすべて小文字にする
    def downcase_email
      self.email = email.downcase
    end

    # 有効化トークンとダイジェストを作成および代入する
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end

この時点でのアプリケーションは、無効なメールアドレスを入力した場合に正常に動作します。
正しいメールアドレスを送信した場合にはまだメイラーが実装されてないのでエラーを表示します。
アプリケーションが正常に動作するためには、パスワード再設定のメイラーメソッドを定義する必要があります。(該当するuserオブジェクトにはreset_digestとreset_sent_atがある

12.2 パスワード再設定のメール送信

11章でUserメイラー (app/mailers/user_mailer.rb) を生成したときに、デフォルトのpassword_resetメソッドもまとめて生成されているはず

12.2.1 パスワード再設定のメールとテンプレート

11.3.3では、UserメイラーにあるコードをUserモデルに移すリファクタリングを行いました。同様のリファクタリング作業を、パスワード再設定に対しても行っていきましょう

UserMailer.password_reset(self).deliver_now

上のコードの実装に必要なメソッドは、11.2で実装したアカウント有効化用メイラーメソッドとほぼ同じです。
最初にUserメイラーにpassword_resetメソッドを作成し、続いて、2つ自動作成されているテンプレートのテキストメールのテンプレート とHTMLメールのテンプレート をそれぞれ定義します。

app/mailers/user_mailer.rb
 class UserMailer < ApplicationMailer

  def account_activation(user)
    @user = user
    mail to: user.email, subject: "Account activation"
  end

  def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end
end
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>

次にアカウント有効化メールの場合 (11.2) と同様、Railsのメールプレビュー機能でパスワード再設定のメールをプレビューしましょう

test/mailers/previews/user_mailer_preview.rb
 # Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/account_activation
  def account_activation
    user = User.first
    user.activation_token = User.new_token
    UserMailer.account_activation(user)
  end

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    user = User.first
    user.reset_token = User.new_token
    UserMailer.password_reset(user)
  end
end

一度URLを入力して確認しておきましょう

有効化のときと同様いまは有効な情報を送信するとサーバーログに表示されます。
コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認しておきましょう。

12.2.2 送信メールのテスト

test/mailers/user_mailer_test.rb
 require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

  test "account_activation" do
    user = users(:michael)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name,               mail.body.encoded
    assert_match user.activation_token,   mail.body.encoded
    assert_match CGI.escape(user.email),  mail.body.encoded
  end

  test "password_reset" do
    user = users(:michael)
    user.reset_token = User.new_token
    mail = UserMailer.password_reset(user)
    assert_equal "Password reset", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.reset_token,        mail.body.encoded
    assert_match CGI.escape(user.email),  mail.body.encoded
  end
end

GREEN

12.3 パスワードを再設定する

newビューからcreateアクション経由でメールの送信が完了したので
ここからは添付したURLつまりEdit関連を実装、そのあとupdateアクションを完了させて
再設定を完結させる

12.3.1 editアクションで再設定

ユーザーの編集フォームと似ていますが、今回はパスワード入力フィールドと確認用フィールドだけで十分です。

ただし、今回の作業は少しだけ面倒な点があります。
というのも、メールアドレスをキーとしてユーザーを検索するためには、editアクションとupdateアクションの両方でメールアドレスが必要になるからです。例のメールアドレス入りリンクのおかげで、editアクションでメールアドレスを取り出すことは問題ありません。しかしフォームを一度送信してしまうと、この情報は消えてしまいます。この値はどこに保持しておくのがよいのでしょうか。今回はこのメールアドレスを保持するため、隠しフィールドとしてページ内に保存する手法をとります。これにより、フォームから送信したときに、他の情報と一緒にメールアドレスが送信されるようになります。

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
これまでは次のようなコードを書いていましたが、今回は書き方が異なっています。

f.hidden_field :email, @user.email

これは再設定用のリンクをクリックすると、今回の (hidden_field_tag) ではメールアドレスがparams[:email]に保存されます
前回までの (f.hidden_field)のように変数fを加えてしまうとparams[:user][:email] に保存されてしまうからです。
これはコントローラ内の@user = User.find_by(email: params[:email])で対応スルユーザーをメールアドレスで検索する
際に応用されるわけだ

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

    # 正しいユーザーかどうか確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))<--注目
        redirect_to root_url
      end
    end
end

注目・・・ここでも抽象化したauthenticated?メソッドを使って、このユーザーが正当なユーザーである (ユーザーが存在する、有効化されている、認証済みである) ことを確認します。editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、beforeフィルタを使って@userの検索とバリデーションを行います

今一度ここで認証メソッドを確認しておく
image.png

12.3.2 パスワードを更新する(updateアクション)

このupdateアクションでは、次の4つのケースを考慮する必要があります。

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

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]    # (1) への対応

  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?                  # (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

  private

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

    # beforeフィルタ

    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

(1)の対策・・・beforeフィルターで期限切れかどうかメソッドを呼び出している。

(2)(3)(4)の対策・・・if文の条件分岐でそれぞれコードに落とし込んでいる

この中でも小難しいのが(3)パスワードが空文字だった場合の処理です。というのも、以前Userモデルを作っていたときに、パスワードが空でも良い (allow_nil)という実装をしたからです。したがって、このケースについては明示的にキャッチするコードを追加する必要があります
、次のようにerrors.addを使ってエラーメッセージを追加します。

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

このようにrailsに定義されているerrorsメソッドで書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれるようになります。

注目・・・@user.password_reset_expired?
このメソッドをUserモデルで定義していきましょう。このメソッドではパスワード再設定の期限を設定して、2時間以上パスワードが再設定されなかった場合は期限切れとする処理を行います。

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

  private
    .
    .
    .
end

注釈:上の < 記号を「〜より少ない」と読んでしまうと、「パスワード再設定メール送信時から経過した時間が、2時間より少ない場合」となってしまい、困惑してしまうので注意してください。ここで行っている処理は、「少ない」ではなく「早い」と捉えると理解しやすいです。つまり、< 記号を「〜より早い時刻」と読んでください。こうすると「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前 (早い) の場合」となり、 期待どおりの条件となります。

これでupdateアクションが作動します。

12.3.3 パスワードの再設定をテストする

この項では、2つ (または期限切れを含めて3つ) の分岐、つまり送信に成功した場合と失敗した場合の統合テストを作成します

$ rails generate integration_test password_resets
      invoke  test_unit
      create    test/integration/password_resets_test.rb

テストする手順は、アカウント有効化のテスト (リスト 11.33) と多くの共通点がありますが、テストの冒頭部分には次のような違いがあります。
最初に「forgot password」フォームを表示して無効なメールアドレスを送信し→次はそのフォームで有効なメールアドレスを送信→するとパスワード再設定用トークンが作成され→再設定用メールが送信→メールのリンクを開いて無効な情報を送信→次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを確認します。

このテストはコードリーディングのよい練習台になりますので、みっちりお読みください。

test/integration/password_resets_test.rb
 require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end

  test "password resets" do
    get new_password_reset_path
    assert_template 'password_resets/new'
    # メールアドレスが無効
    post password_resets_path, params: { password_reset: { email: "" } }
    assert_not flash.empty?
    assert_template 'password_resets/new'
    # メールアドレスが有効
    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
    # パスワード再設定フォームのテスト
    user = assigns(:user)
    # メールアドレスが無効
    get edit_password_reset_path(user.reset_token, email: "")
    assert_redirected_to root_url
    # 無効なユーザー
    user.toggle!(:activated)
    get edit_password_reset_path(user.reset_token, email: user.email)
    assert_redirected_to root_url
    user.toggle!(:activated)
    # メールアドレスが有効で、トークンが無効
    get edit_password_reset_path('wrong token', email: user.email)
    assert_redirected_to root_url
    # メールアドレスもトークンも有効
    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<--注目1
    # 無効なパスワードとパスワード確認
    patch password_reset_path(user.reset_token),
          params: { email: user.email,
                    user: { password:              "foobaz",
                            password_confirmation: "barquux" } }
    assert_select 'div#error_explanation'
    # パスワードが空
    patch password_reset_path(user.reset_token),
          params: { email: user.email,
                    user: { password:              "",
                            password_confirmation: "" } }
    assert_select 'div#error_explanation'
    # 有効なパスワードとパスワード確認
    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
  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_sent_at, 3.hours.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<--注目2
  end
end
end

注目1・・・assert_select "input[name=email][type=hidden][value=?]", user.email
このコードは、inputタグに正しい名前、type="hidden"、メールアドレスがあるかどうかを確認します。

<input id="email" name="email" type="hidden" value="michael@example.com" />

注目2・・・response.bodyは、そのページのHTML本文をすべて返すメソッドです。ここではassert_matchで全文に「expired」という語があるかどうかでチェックしている

GREEN

パスワード再設定が成功したら二度と同じリクエストを通さない

2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。しかし、例えば公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを表示させ、パスワードを更新してしまうことができてしまいます (しかもそのままログイン機構まで突破されてしまいます!)。

この問題を解決するために、コードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう

app/controllers/password_resets_controller.rb
 class PasswordResetsController < ApplicationController
  .
  .
  .
  def update
    if params[:user][:password].empty?
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)
      log_in @user
      @user.update_attribute(:reset_digest, nil)
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
end

テストにも追加

test/integration/password_resets_test.rb
 require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end

  test "password resets" do


    assert_redirected_to user
    assert_nil user.reload['reset_digest']   <--追加
  end