kuma0319のブログ

webエンジニアになるためのアウトプットブログ

Ruby on Railsチュートリアル第12章

Railsチュートリアルの第12章を進めていきます。

第12章(パスワードの再設定)

PasswordResetsリソース

基本的にはアカウント有効化の流れと同じ。

同様な部分
パスワード再設定用のトークンを含めたメールを送信し、そのリンクをクリックするとデータベース内のパスワード再設定ダイジェストとトークンを比較し認証に成功したら、パスワードが変更可能となる。

異なる部分
パスワード再設定用のフォーム(ビュー)やモデル内のパスワードを変更するためのフォームが必要。
パスワード再設定用のリンクにはなるべく短時間の有効期限を設ける必要がある。

パスワード再設定を加えたモデル

カラム名 データ型
id integer
name string
email string
created_at datetime
updated_at datetime
password_digest string
remember_digest string
admin boolean
activation_digest string
activated boolean
activated_at datetime
reset_digest string
reset_sent_at datetime
パスワード再設定のcreateアクション

ユーザーが再設定用のフォームからPOSTした際の動作。

  1. postされたメールアドレス(params[:password_reset][:email])を元にユーザーを検索。
  2. そのユーザーが存在すれば、再設定用ダイジェストを作成。
  3. リセット用のメールを送信。
  4. フラッシュメッセージを表示し、rootへリダイレクト。

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

再設定メールようのメーラーとテンプレートはほとんどアカウントの有効化と同一。
メーラー単体テストの内容もアカウント有効化と同じ。

パスワードを再設定

隠しフィールド

editアクションに対するメールアドレス入りのリンクがあるため、editアクションではユーザー検索のためのkeyとしてメールアドレスを使用出来る。しかし、updateアクションでも必要なためどこかに保持しておく必要がある。
隠しフィールドとしてビューの中に埋め込むことでメールアドレスの情報も保持される。

 <%= hidden_field_tag :email, @user.email %>
f.hidden_fieldとhidden_field_tagについて

この2つの使い分けがいまいち分かりにくかったので、以下のサイトを参考にしました。
f.hidden_fieldは、form_forform_withで使用され、モデルのインスタンスに対する属性と値を隠しフィールドとして渡している。モデルにネストしているためパラメータはparams[:user][:email]のようにネストしたハッシュになる。
対して、hidden_field_tagはモデルに対して渡している訳ではなく、単にkeyとvalueの形で各フィールドに値を埋め込んでいる。モデルにネストしていないため、パラメータはparams[:email]のようになる。

sakurawi.hateblo.jp

updateアクション

パスワードのupdateではいくつか考慮する事項がある。以下はその事項と対策。

  1. パスワード再設定の有効期限  ⇒ before_action :check_expiration, only: [:edit, :update]で有効期限を確認するcheck_expirationメソッドを定義し、有効期限をチェック。
  2. 無効なパスワードは失敗させる  ⇒ 失敗時の動作としてeditビューの再描画とエラーメッセージを表示させる。
  3. 新規パスワードと確認欄が空文字列でないか  ⇒ モデルに対してallow_nilオプションを付けているため、パスワードと確認フィールド両方が空文字の場合に素通りするため、errors.addでエラーメッセージを追加出来る。
  4. 新規パスワードが問題無ければ更新  ⇒ 成功時のパスワードを更新し、ログイン+メッセージ表示+リダイレクト処理。更新可能なパラメータはStrong Parametersで指定。
[app/controllers/password_resets_controller.rb]
  def update
    if params[:user][:password].empty?                  # (3)への対応
      @user.errors.add(:password, :blank)
      render 'edit', status: :unprocessable_entity
    elsif @user.update(user_params)                     # (4)への対応
      @user.forget                                         # セッションハイジャックの対応として、記憶トークンを無効化する。
      reset_session
      log_in @user
      @user.update_attribute(:reset_digest, nil) #再設定後にリセットダイジェストを悪用されないようにnilにしておく。
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit', status: :unprocessable_entity      # (2)への対応
    end
  end

  private

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

更に、演習より、 update後は@user.forgetをすることで、記憶トークンを削除しておき、セッションハイジャックから保護しておく。また、update後にリセットダイジェストをnilへ更新しておくことで、例えば公共のパソコン等でパスワードを変更し終えた後に三者が更にそのリセットダイジェストを使用してパスワードを更新するような事態を防ぐことが出来る。

第12章ー演習ー

12.1.1

①この時点で、テストスイートが green になっていることを確認してみましょう。
(解答) greenになっている。

②表 12.1の名前付きルーティングでは、pathではなくurlを使っているのはなぜでしょうか?理由を考えてみましょう。(ヒント: アカウント有効化の演習11.1.1.1と同じ理由です。)
(解答) railsアプリケーションの外部からアクセスするため、相対パスでは無く絶対パスで示す必要がある。

12.1.2

①リスト 12.4のform_withメソッドで、@password_resetではなく:password_resetを使っている理由を考えてみましょう。
(解答) セッションと同様に、password_resetモデルが無いため@password_resetというインスタンス変数は存在しない。そのためscope: :password_resetとリソースを明示的に渡す必要がある。

12.1.3

①試しに有効なメールアドレスをフォームで送信してみましょう(図 12.6)。どんなエラーメッセージが表示されますか?
(解答)以下のエラーが発生。
ArgumentError in PasswordResetsController#create wrong number of arguments (given 1, expected 0)

  end

  def password_reset
    @greeting = "Hi"

    mail to: "to@example.org"

②上の演習課題で送信した結果はエラーと表示されますが、該当するuserオブジェクトにはreset_digestとreset_sent_atが存在することをコンソールで確認してみましょう。それぞれの値はどのようになっていますか?
(解答) 生成されている。

 UPDATE "users" SET "updated_at" = ?, "reset_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2023-04-20 04:09:10.810101"], ["reset_digest", "$2a$12$.9wbuYMbGDKnQ5HBOF/TneJ89rYsEl/Cgfrmow5Tj5W9765acgNwC"], ["id", 1]]

UPDATE "users" SET "updated_at" = ?, "reset_sent_at" = ? WHERE "users"."id" = ?  [["updated_at", "2023-04-20 04:09:10.829845"], ["reset_sent_at", "2023-04-20 04:09:10.829189"], ["id", 1]]
  ↳ app/models/user.rb:65:in `create_reset_digest'

12.2.1

①送信メールをブラウザでプレビューしてみましょう。「Date」フィールドにはどんな情報が表示されていますか?
(解答) 現在の日時

②パスワード再設定フォームで有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。
(解答) ちゃんとパスワードリセットトークン付きのリンクが入ったメールが送信されている。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <h1>Password reset</h1>

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

<a href="http://localhost:3000/password_resets/LkGibqRrg1irrOK-_4Mzgw/edit?email=example%40railstutorial.org">Reset password</a>

<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>
  </body>
</html>

Railsコンソールを開いて、上の演習課題でパスワードを再設定したUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認してみましょう。
(解答)

irb(main):002:0> user.reset_digest
=> "$2a$12$V3IiQHXoGECfW32djzItYuvSclU87pwi8U3CjxxNdOB8gwWHPTYC."
irb(main):003:0> user.reset_sent_at
=> Thu, 20 Apr 2023 05:45:21.365272000 UTC +00:00

12.2.2

メーラーのテストだけを実行してみてください。このテストは green になりますか?
(解答)green。

②リスト 12.12にある2つ目のCGI.escapeメソッドを削除すると、テストが red になることを確認してみましょう。
(解答)エスケープを外すとredになる。

12.3.1

①演習12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているパスワード再設定用リンクを見つけてください。そのリンクをブラウザで表示すると、図 12.11のようになることを確かめてみましょう。
(解答) ちゃんと表示された。

②上で表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になりましたか?
(解答) 更新されない。

12.3.2

①演習12.2.1.1でRailsサーバーのログから取得できるリンクをブラウザで表示し、passwordフィールドとconfirmationフィールドの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されますか?
(解答)Password confirmation doesn't match Password

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

irb(main):002:0> user.password_digest
=> "$2a$12$kmhU7VMMV.L73dSn7G723eYos1Szqs/2Bgy2eLIK5RJ8ryTU3Aiam"

変更後

irb(main):003:0> user.reload.password_digest
  User Load (44.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> "$2a$12$zntqk4ZU1p3Rez.2884l3.Llp2d4GWeFZkiK1c.SaNUEoL6s9xXvO"     

③演習9.3.2.1では、セッションハイジャック(9.1.1)から保護するためのセッショントークンを実装しました。想定されるシナリオの1つとして「セッションを盗まれたことに気づいたユーザーが即座にパスワードをリセットする」という状況が考えられます。特に、ハイジャックされたセッションをこの操作で自動的に失効させることができたら素晴らしいでしょう。この機能を実現するのに必要なコードをリスト 12.18の(コードを書き込む)の部分に書いてください。(ヒント: リスト 9.37で、記憶ダイジェストをセッショントークンとして再利用したこと、Userモデルにはリスト 9.11で示した記憶トークンを削除するメソッドが既にあることを思い出しましょう。)
(解答)

[app/controllers/password_resets_controller.rb]
  def update
    if params[:user][:password].empty?                  # (3)への対応
      @user.errors.add(:password, :blank)
      render 'edit', status: :unprocessable_entity
    elsif @user.update(user_params)                     # (4)への対応
      @user.forget                                      # セッションハイジャックの対応として、記憶トークンを無効化する。
      reset_session
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit', status: :unprocessable_entity      # (2)への対応
    end
  end

12.3.3

①リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 12.21に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう。これでデータベースへの問い合わせが1回で済むようになります。また、変更後にテストを実行し、 green になることも確認してください。ちなみにリスト 12.21にあるコードには、前章の演習(リスト 11.40)の解答も含まれています。(注: update_columnsはバリデーションが実行されない上、update_attributeと異なりモデルのコールバックも行われないため、本チュートリアル以外で使用する際は注意が必要です。)
(解答)

  def create_reset_digest
    self.reset_token = User.new_token
    update_columns(reset_digest:  User.digest(reset_token), reset_sent_at: Time.zone.now)
  end

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

  test "should include the word 'expired' on the password-reset page" do
    follow_redirect!
    assert_match /expired/i, response.body
  end

③2時間経過するとパスワードを再設定できないようにする機能はセキュリティ的に好ましい手法ですが、公共の場所に設置されているコンピュータや、複数のユーザーが共有するコンピュータが使われる可能性も考慮すれば、より安全性の高いセキュリティ対策にするのが望ましいでしょう。公共の場所で共有されるコンピュータの多くは誰でもログインできるので、あるユーザーがそのコンピュータから離席するときに正しくログアウトしていたとしても、別のユーザーが2時間以内にそのコンピューターにログインし、ブラウザの「戻る」ボタンを数回押してパスワード再設定フォームを見つけたら、パスワード更新に成功してしまう可能性があり、しかもそのままログインされてしまいます。この問題を解決するために、リスト 12.23のコードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう6 。 リスト 12.19に1行追加し、上の演習課題に対するテストを書いてみましょう。(ヒント: リスト 9.26のassert_nilメソッドとリスト 11.34のuser.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。)
(解答)assert_nil @reset_user.reload.reset_digestを追加。updateアクションの@user.update_attribute(:reset_digest, nil)コメントアウトするとテストREDとなるため、正しく機能している。

  test "update with valid password and confirmation" do
    patch password_reset_path(@reset_user.reset_token),
          params: { email: @reset_user.email,
                    user: { password:              "foobaz",
                            password_confirmation: "foobaz" } }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to @reset_user
    assert_nil @reset_user.reload.reset_digest
  end

12.4

①production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールが届きましたか?
②メールを受信できたら、実際にメール内のリンクをクリックしてアカウントを有効化してみましょう。また、Render上のログを表示して、有効化に関するログを詳しく調べてみてください。
③アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正常にパスワードを再設定できましたか?
飛ばします。

第12章ーまとめー

  • パスワード再設定はアカウント有効化と同様でリソース化できる。
  • パスワード再設定のリンクには短時間(2時間程度)の有効期限を設ける。
  • f.hidden_fieldはモデルと紐づけた隠しフィールドとして機能し、hidden_field_tagはモデルと紐づいていない隠しフィールドとして機能する。
  • errors_addで引数のエラーが発生した場合のエラーメッセージの動作を追加出来る。
  • パスワードの再設定に成功(update)した場合のアクションには、セッションハイジャックを防止するために記憶トークンを削除しておくことや、第三者が有効期限の切れていないリセットダイジェストから再度パスワード変更するような状況を防ぐためにリセットダイジェストを削除しておくことも重要。

第12章終わり🐻