紙一重の積み重ね

アラフォーのエンジニアがなれる最高の自分を目指して、学んだことをこつこつ情報発信するブログです。

【12章】Ruby on Railsチュートリアル演習まとめ&解答例【12.3 パスワードを再設定する】

はじめに

Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版の 12章 12.3 パスワードを再設定するの演習まとめ&回答例です。

個人の解答例なので、誤りがあればご指摘ください。

動作環境

  • cloud9
  • ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-linux]
  • Rails 5.0.0.1

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

パスワード再設定メール送信後、URLをクリックした場合の処理を作成する。 PasswordResetsコントローラのeditアクションを実装していく。

12.3.1 editアクションで再設定

本章での学び

【view】パスワード再設定のフォームを作成する

自動生成されたフォームの中身を実装する。

<h1>PasswordResets#edit</h1>
<p>Find me in app/views/password_resets/edit.html.erb</p>

メールアドレスを下記のとおり取得できるようにする。

  • editアクション
    • メールアドレス入りのリンクから取得
  • updateアクション
    • hiddenフィールドに保持(hidden_field_tagを使用する)
    • params[:email]から取得
<% 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>

【view】フォームタグヘルパー

記載方法が似ているが、保存先のハッシュが異なる。

  • hidden_field_tag :email, @user.email
    • メールアドレスが、params[:email]に保存される
  • f.hidden_field :email, @user.email
    • メールアドレスが、params[:user][:email]に保存される

【controller】パスワード再設定のeditアクションを実装する

editアクション内の実装は不要。 beforeアクションで、emailからユーザーを検索し、正しいユーザーかどうかを確認する。

正しいユーザーの定義は以下の通り。

  • ユーザーが存在すること
  • 有効化されていること
  • 認証済みであること

この3つを満たさない場合は、ルートURLにリダイレクトする。

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

動作確認

ブラウザを起動し、パスワード再設定メールを送信する。 パスワード再設定メールは、コンソール上に表示される。

To reset your password click the link below:

https://xxxxxxxx/password_resets/KEiOUcT0bJfsZhiNlmMG2g/edit?email=example%40railstutorial.org

This link will expire in tow hours.

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

メール内のパスワード再設定URLにアクセスすると、 パスワード再設定画面が表示されることを確認。

image

演習1

12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて、図 12.11のように表示されるか確かめてみましょう。

前述の「動作確認」参照。

演習2

先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?

updateアクションが存在しないためエラーとなる。

12.3.2 パスワードを更新する

本章での学び

フォームからの送信に対応するupdateアクションを実装する。 以下、4つの手順で実装する。

手順1.パスワード再設定の有効期限が切れていないか

editアクションと、updateアクションにbeforeフィルターを追加して、 パスワード有効期限切れチェックを行う。

【model】パスワード有効期限切れチェックを追加する

Userモデルにインスタンスメソッドを追加する。 「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前(早い)」の場合、期限切れとみなす。

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

【controller】パスワード有効期限切れチェックを追加する

class PasswordResetsController < ApplicationController
・・・略・・・
  before_action :check_expiration, only: [:edit, :update] # (1)への対応

  private
・・・略・・・
    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end

手順2.無効なパスワードであれば失敗させる (失敗した理由も表示する)

beforeフィルターで保護したupdateアクションを使う。 DBの更新に失敗した時に、editビューが再描画され、パーシャルにエラーメッセージを表示する。

手順3.新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)

ハッシュからパスワードを取得して、空白ならば、@userにエラーメッセージを追加する。

手順4.新しいパスワードが正しければ、更新する

ストロングパラメータを使ってDB更新が成功した場合、ログイン状態にして成功メッセージを表示させる。

【controller】手順2〜4の実装まとめ

上記の手順2〜4をupdateアクションに実装する。

  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
    
    # Strong parameters
    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end

動作確認

ブラウザを起動し、パスワード再設定を行えることを確認。

image

演習1

12.2.1.1で得られたリンク (Railsサーバーのログから取得) をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?

パスワード image

演習2

コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう (図 12.13)。パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。ヒント: 新しい値はuser.reloadを通して取得する必要があります。

コンソールから、パスワード再設定を送信したユーザーオブジェクトを検索し、 password_digestの値を取得する。

yokoyan:~/workspace/sample_app (password-reset) $ rails console
Running via Spring preloader in process 1646
Loading development environment (Rails 5.0.0.1)
>> user = User.first
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2017-06-20 04:07:12", updated_at: "2017-07-12 21:58:37", password_digest: "$2a$10$A2LTYT9xQH3HYqyzUp1uoutfRsE/qb3I9H5S1M4iBAT...", remember_digest: nil, admin: true, activation_digest: "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/...", activated: true, activated_at: "2017-06-20 04:07:12", reset_digest: "$2a$10$Omi.3o6nMIUUXetPcnvRlOYLV5AwlUW2MVKV6cCJdK0...", reset_sent_at: "2017-07-12 21:58:37">
>> 
?> user.password_digest
=> "$2a$10$A2LTYT9xQH3HYqyzUp1uoutfRsE/qb3I9H5S1M4iBAT.2xnP/xk2O"

パスワードの再設定を行う。

image

コンソールからリロードして、password_digestの値が更新されていることを確認。

?> user.reload
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2017-06-20 04:07:12", updated_at: "2017-07-15 00:13:29", password_digest: "$2a$10$UZx78WvxKtgCihJNy.674Oj72HHCUTjc0HczrEu5yXu...", remember_digest: nil, admin: true, activation_digest: "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/...", activated: true, activated_at: "2017-06-20 04:07:12", reset_digest: "$2a$10$mpcTDln7FOmzP2UaCv7qieLT5OQiIO8KbxHZ53PQuGz...", reset_sent_at: "2017-07-15 00:13:03">
>> 
?> user.password_digest
=> "$2a$10$UZx78WvxKtgCihJNy.674Oj72HHCUTjc0HczrEu5yXuhI2jao7cYG"

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

本章での学び

送信に成功した場合と、失敗した場合の統合テストを作成する。

【test】パスワード再設定のテストファイルを作成

yokoyan:~/workspace/sample_app (password-reset) $ rails generate integration_test password_resets
Running via Spring preloader in process 1685
Expected string default value for '--jbuilder'; got true (boolean)
      invoke  test_unit
      create    test/integration/password_resets_test.rb

【test】パスワード再設定の統合テストの実装

事前準備
  • 事前準備
    • 配信方法が:testの場合、メールを保存する配列をクリアする
    • michaelの情報を取得し、インスタンス変数@userに格納
  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end
メールアドレスが無効の場合
  • テストメソッド
    • GETリクエストを送信する。パスワードリマインダー画面(new_password_resets_path)へ。
    • パスワードリマインダー画面のテンプレートが表示されること
    • POSTリクエストを送信する。(password_resets_path)
      • メールアドレスはブランク
    • フラッシュメッセージが空白ではないこと(エラーメッセージが表示)
    • パスワードリマインダー画面のテンプレートが表示されること
    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_reset_path)
      • メールアドレス:@userのメールアドレス
    • @userのパスワードダイジェストと、@userをリロードしたパスワードダイジェストが一致しないこと(変更されていること)
    • メイラーの配列に保存された件数が1件であること
    • フラッシュメッセージが空白ではないこと(成功メッセージが表示)
    • root_urlにリダイレクトされること
    # メールアドレスが有効
    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の代わりに、userを今後使用
    • 参考:テスト(test)
    # パスワード再設定フォームのテスト
    user = assigns(:user)
メールアドレスが無効の場合
  • GETリクエストを送信する(edit_password_reset_path)
    • リセットトークン:user.token
    • メールアドレス:ブランク
  • root_urlにリダイレクトする
    # メールアドレスが無効の場合
    get edit_password_reset_path(user.reset_token, email:"")
    assert_redirected_to root_url
無効なユーザー
  • userの有効化フラグを逆転
  • GETリクエストを送信する。(edit_password_reset_path)
    • リセットトークン:user.token
    • メールアドレス:user.email
  • root_urlにリダイレクトすること
  • userの有効化フラグを逆転(元に戻す)
    # 無効なユーザー
    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
    • メールアドレス:user.email
  • root_urlにリダイレクトすること
    # メールアドレスが有効、トークンが無効
    get edit_password_reset_path('wrong token', email: user.email)
    assert_redirected_to root_url
メールアドレスもトークンも有効
  • GETリクエストを送信する。(edit_password_reset_path)
    • リセットトークン:user.reset_token
    • メールアドレス:user.email
  • パスワード再設定画面のテンプレートが表示されること
  • 画面のHTMLに、以下が表示されていること
    • input属性
    • name:email
    • type:hidden
    • value:user.email
    # メールアドレスもトークンも有効
    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
無効なパスワードとパスワード確認
  • PATCHリクエストを送信する。(password_reset_path)
    • email:user.email
    • user
      • password:foobaz
      • password_confirmation:barquux
  • 画面のHTML、以下が表示されていること(エラーになること)
    • div#error_explanation
    # 無効なパスワードとパスワード確認
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "foobaz",
                password_confirmation: "barquux"
            }
        }
    assert_select 'div#error_explanation'
パスワードが空
  • PATCHリクエストを送信する。(password_reset_path)
    • email:user.email
    • user
      • password:ブランク
      • password_confirmation:ブランク
  • 画面のHTML、以下が表示されていること(エラーになること)
    • div#error_explanation
    # パスワードが空
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "",
                password_confirmation: ""
            }
        }
    assert_select 'div#error_explanation'
有効なパスワードとパスワード確認
  • PATCHリクエストを送信する。(password_reset_path)
    • email:user.email
    • user
      • password:foobaz
      • password_confirmation:foobaz
  • ログイン状態であることを確認
  • フラッシュメッセージが空ではないこと(成功メッセージが表示)
  • ユーザープロフィール画面へリダイレクトする
    # 有効なパスワードとパスワード確認
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "foobaz",
                password_confirmation: "foobaz"
            }
        }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user

演習1

リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 12.20に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。また、変更後にテストを実行し、 greenになることも確認してください。ちなみにリスト 12.20にあるコードには、前章の演習 (リスト 11.39) の解答も含まれています。

2回DBに更新している箇所を、1回にまとめる。

  # パスワード再設定の属性を追加する
  def create_reest_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_sent_at: Time.zone.now)
  end

テストがgreenになることを確認。

yokoyan:~/workspace/sample_app (password-reset) $ rails test                                                                                     
Running via Spring preloader in process 6011                                                                                                     
Started with run options --seed 39983                                                                                                            
                                                                                                                                                 
  46/46: [==================================================================================================] 100% Time: 00:00:02, Time: 00:00:02
                                                                                                                                                 
Finished in 2.42747s                                                                                                                             
46 tests, 221 assertions, 0 failures, 0 errors, 0 skips     

演習2

リスト 12.16のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐 (リスト 12.21) を統合テストで網羅してみましょう (12.21 のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです)。 期限切れをテストする方法はいくつかありますが、リスト 12.21でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます (なお、大文字と小文字は区別されません)。

  • getリクエストを送信する。(new_password_reset_path)
  • postリクエストを送信する。(password_reset_path)
    • params
      • password_reset
        • メールアドレス;@user.email
  • パスワード再設定フォームのテストのために、アクションを実行結果、インスタンス変数に代入されたオブジェクトを取得
    • @userにassigns(:user)の結果を代入
  • パスワード再設定メール送信時間を3時間前に更新する
  • patchリクエストを送信する。(password_reset_path(@user.reset_token))
    • params
      • password_reset
        • メールアドレス:@user.email
      • user
        • パスワード:foobar
        • 確認用パスワード:foobar
  • レスポンスが302であること
  • 単一のリダイレクトのレスポンスに従う
  • レスポンスで返されたHTMLの中身に、expired(期限切れ)の文字列が存在すること
  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/i, response.body
  end

追加した統合テストがgreenになることを確認。

yokoyan:~/workspace/sample_app (password-reset) $ rails test
Running via Spring preloader in process 7793
Started with run options --seed 25242

  47/47: [=====================================================================================================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.45340s
47 tests, 224 assertions, 0 failures, 0 errors, 0 skips

演習3

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

パスワードの再設定に成功したら、ダイジェストをnilに変更する。

  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
      @user.update_attribute(:reset_digest, nil)
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit' # (2)への対応
    end
  end

演習4

リスト 12.18に1行追加し、1つ前の演習課題に対するテストを書いてみましょう。ヒント: リスト 9.25のassert_nilメソッドとリスト 11.33のuser.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。

パスワードの再設定に成功後、user.reload後のreset_digest属性がnilになっていることを確認する。

    # 有効なパスワードとパスワード確認
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "foobaz",
                password_confirmation: "foobaz"
            }
        }
    assert_nil user.reload.reset_digest # 追加
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user

おわりに

本章のテストコードは読み応えがあり、読み解いてテストコードを書くのに時間がかかりました。 また、作らなくてもいいテストを作ってしまい、テストが動かないという問題にハマってしまいました・・・。(こちらに投稿しました。Railsチュートリアルの12章で統合テスト実行時に302: Foundが出る場合の対処法

こういう失敗を糧に、熟練を目指していきます!