はじめに
Railsチュートリアル12章の内容を、少しでも理解の助けとなればと思い、割としっかり目に整理しました!
備忘録です。
前提
Railsチュートリアル1〜11章までの内容が完了していること。
特に、アカウント有効化機能が実装されている事。
内容
Railsチュートリアル12章のパスワード再設定機能の実装手順を、前中後半の3回に分けて整理おります。
後編である今回は、パスワード再設定機能の本実装を行っていきます!
前編→パスワード再設定用のリソース作成
中編→パスワード再設定メール送信機能の実装
3.パスワードを再設定する。
●送信用メールの作成が完了したので、次はPasswordResetsコントローラのeditアクションの実装を行っていく。
●その次に統合テストを使って上手く動作しているかを確認するためのテストを書いていく。
3-1.パスワード再設定機能実装のための準備を行っていく。
1.まずはパスワード再設定用メールに添付してあるリンクを動作させるために、パスワード再設定フォームのビューを作成する必要がある。
➡︎app/views/password_resets/edit.html.erbファイルで行う。
➡︎フォームに関してはパスワード入力フィールドと、パスワード確認入力フィールドを作成すれば十分。
2.パスワードを再設定を行おうとしているユーザーを取得する必要がある。
➡︎ユーザーがメールに貼り付けられているリンクをクリックしてパスワード再設定ページへ移動してくる時、そしてパスワードを実際に再設定する時にユーザーを取得する必要がある。
➡︎メールアドレスをキーとして取得するのだが、そのためにはeditアクションとupdateアクションの両方で、メールアドレスが必要となる。
→editアクションではリンクに含まれているメアドから検索してそのまま取得できる。
→updateアクションでは、フォームを送信するとメールアドレスの情報は消えてしまうので、上手くユーザーを取得できるように準備をしなければならない。
➡︎updateアクションでメールアドレスからユーザーを取得するための準備。
→どうすれば良いかというと、リンクに含まれているメアドを一時的に保持してあげればいい。そのために隠しフィールドを用意してページ内に一旦保持させる手法をとることにする。
→隠しフィールドを用意することで、パスワード再設定の情報と一緒にメールアドレスも送信されるようになる。
→パスワード再設定用のフォームにhidden_field_tagメソッドを使う。キーに:email属性、値に@user.emailでユーザーのアドレスがparams[:email]の値として送信されるようにする。
→<%= hidden_field_tag :email, @user.email %>
→注意。f.hidden_field_tagとしないこと。こうしてしまうと、form_for(@user)のブロック引数を受け取って、params[:user][:email]にメールアドレスが保存されてしまう。
→しかし、これにてメールアドレスからユーザーを検索して取得する準備が整いました。
3.次に上記のフォームを描画するための処理を、PasswordResetsコントローラのeditアクションへ記述してあげる。
➡︎app/controllers/password_resets_controller/rbファイルへ移動する。
→まずprivateキーワード内に、メールアドレスと一致するユーザーの検索を行うメソッドを定義する。
→メソッド名をget_userとする。
→params[:email]のメールアドレスを持つユーザーを取得して、@userインスタンス変数へ代入する処理を記述する。
→同じくprivateキーワード内で、取得したユーザーが有効なユーザーかどうかを確認するメソッドを定義する。
→メソッド名をvalid_userとする。
→そのユーザーが存在する事、有効化されている事、認証済みである事をunless文により判定する条件式を記述する。
→@user(ユーザーの存在) && @user.activated?(有効化の確認) && @user.authenticated?(:reset, params[:id])(ダイジェストとトークンの一致を確認)とする。
→正しいユーザーでなければ、ルートURLへダイレクトさせる処理を記述。
→有効なユーザーかどうかの確認は、editアクションで必要なのはもちろんupdateアクションでも必要になるので、beforeフィルターを使ってバリデーションを行ってあげる。
➡︎以上でパスワード再設定用のフォームが描画されるようになったので、あとはupdateアクションにパスワードを実際に更新するための処理を記述していく。
app/views/password_resets/edit.html.erb#3-1.2 <% provide(:title, 'パスワードの再設定') %> <%= 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 %> <%= f.label :password_confirmation %> <%= f.password_field :password_confirmation %> <%= f.submit "パスワードを更新する" %> <% end %>
app/controllers/password_resets_controller.rb#3-1.3 class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] . . . 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
3-2.実際にパスワードを更新する機能を実装していく。
➡︎上記で作成したフォームからの送信に対応するupdateアクションを作成していく。
➡︎updateアクションの記述を行っていく上で、4つ考慮しなければならない事項がある。
1つ目:パスワード再設定の有効期限が切れていないかどうかを確認する。
➡︎パスワード再設定の期限を設定して、その期限の間に再設定されなかった場合は期限切れとする処理を行うメソッドを定義する。
→app/models/user.rbファイルで記述する。
→メソッド名をpassword_reset_expired?とすることにする。
→期限は2時間とし、現在時刻から2時間以上経っている場合にはtrueを返す処理を記述する。
→reset_sent_at < 2.hours.ago
→注意。「2時間より少ない」という意味ではなく、「2時間以上前」と読むのが正しい。
→つまり、「パスワード再設定メールの送信時刻が、現在時刻よりも2時間以上早い場合」と読むのが正しい。
→準備が完了したので、パスワード再設定の有効期限が切れているかどうかをチェックするメソッドを定義していく。
➡︎パスワード再設定の有効期限が切れていた場合にユーザーを弾くメソッドを定義する。
→app/controllers/password_reset_controller/rbファイルへ移動する。
→メソッド名をcheck_expirationとする。
→取得したユーザーのパスワード再設定有効期限が切れてるかどうかを判定する条件式を記述する。
→上記で準備した「パスワード再設定のメール送信時刻が、現在時刻よりも2時間以上前ならtrueを返す」メソッドを利用する。
→trueならフラッシュで警告メッセージを表示させ、パスワード再設定フォームへリダイレクトさせる。
→このメソッドはprivateキーワード内で定義してあげる。
➡︎edit、updateアクションに対しbeforeフィルターで、check_expirationメソッドを適用してあげる。
2つ目:無効なパスワードであれば失敗させる。そして、失敗した理由も表示させる。
➡︎app/controllers/password_resets_controller.rbファイルのupdateアクションに直接記述していく。
→updateアクション内のどの条件式にも当てはまらない場合はeditのビューを再描画させ、パーシャルにエラーメッセージが表示されるようにする(パスワード再設定フォームに記述済み)。
3つ目:新しいパスワードが空文字になっていないかを確認する。
➡︎app/controllers/password_resets_controller.rbファイルのupdateアクションに直接記述していく。
→フォームから送信されたパスワードが空かどうかを判定する条件式を記述する。
→空ならerrors.addを使って@userオブジェクトを直接指定して、空の文字列に対してデフォルトのメッセージを表示させるようにする。
→@user.errors.add(:password, :blank)
→空でなければ4の処理へ移行させる。
4つ目:新しいパスワードが正しければ、更新する。
➡︎app/controllers/password_resets_controller.rbファイルのupdateアクションに直接記述していく。
→パスワードが更新・保存された場合の条件式を記述する。
→ユーザーをログインさせる。
→ユーザーのリセットダイジェストを、nilで更新・保存する。
→再設定が成功しても最低2時間は再設定が有効になってしまっているので、ダイジェストを空にすることで強制的に無効にさせる。
→フラッシュメッセージを表示させる。
→ユーザー詳細ページへリダイレクトさせる処理を記述する。
➡︎上記の条件式ではupdate_attributesメソッドを使用しているが、引数にuser_paramsメソッドを渡すことでより厳密な属性検索をかけさせるようにしたい。
→なのでprivateキーワード内で、パスワードとパスワード確認のみを許可するためのuser_paramsメソッドを定義してあげる。
→params.require(:user).permit(:password, :password_confirmation)
➡︎これで、updateアクションが動作するようになった。
app/controllers/password_resets_controller.rbclass PasswordResetsController < ApplicationController . . #1つ目:パスワード再設定の有効期限が切れていないかどうかを確認する。 before_action :check_expiration, only: [:edit, :update] . . . def update #3つ目:新しいパスワードが空文字になっていないかを確認する。 if params[:user][:password].empty? @user.errors.add(:password, :blank) render 'edit' #4つ目:新しいパスワードが正しければ、更新する。 elsif @user.update_attributes(user_params) log_in @user flash[:success] = "パスワードが更新されました" redirect_to @user #2つ目:無効なパスワードであれば失敗させる。 else render 'edit' end end private def user_params params.require(:user).permit(:password, :password_confirmation) end . . . #1つ目:パスワード再設定の有効期限が切れていないかどうかを確認する。 def check_expiration if @user.password_reset_expired? flash[:danger] = "パスワード再設定の有効期限が切れてます" redirect_to new_password_reset_url end end end
app/models/user.rbclass User < ApplicationRecord . . . #1つ目:パスワード再設定の有効期限が切れていないかどうかを確認する。 def password_reset_expired? reset_sent_at < 2.hours.ago end . . . end
3-3.パスワードの再設定をテストする。
1.新しいパスワードの送信に成功した場合と、失敗した場合の統合テストを書いていく。
➡︎まずはファイルを作成する。
→$ rails g integration_test password_resets
➡︎test/integration/password_resets_test.rbファイルへ移動して、パスワード再設定の統合テストを記述していく。
→(1)setupメソッドを定義する。
→メイラーの中身が空の配列を作成する。
→テスト用のログインユーザーを取得する。
→(2)パスワード再設定のテストを行う。
→パスワード再設定用メール送信フォームに対してGETリクエストを送信させる記述を行う。
→パスワード再設定用メール送信フォームのビューテンプレートが描画されていることを検証。
→(3)まずは無効なメールアドレスで、パスワード再設定用のメール送信をPasswordResetsリソースのcreateアクションへPOSTリクエストを送信させる記述を行う。
→フラッシュが存在することを検証。
→パスワード再設定用のメールを送信するビューテンプレートが表示されていることを検証。
→(4)今度は有効なメールアドレスで、パスワード再設定用のメール送信をPasswordResetsリソースのcreateアクションへPOSTリクエストを送信させる記述を行う。
→更新後のリセットダイジェストが、更新前のダイジェストと(ダイジェストが空になったことで)イコールでないことを検証する。
→メイラーの配列の値の個数が1になっていることを検証する。
→フラッシュが表示されていることを検証する。
→ルートURLへリダイレクトされていることを検証する。
→(5)パスワード再設定フォームのテストを行う。
→assignsメソッドでPasswordResetsコントローラから直接@userインスタンス変数を参照し、ユーザーを取得しておく。
→(6)無効なメールアドレスで、パスワード再設定フォームへGETリクエストを送信させる記述を行う。
→ルートURLへリダイレクトされることを検証する。
→(7)ここでユーザーを無効なユーザーに切り替える処理を記述(攻撃者によるアカウント乗っ取りのテスト)。
→メールアドレスは有効だが、無効なユーザーでパスワード再設定フォームヘGETリクエストを送信させる記述を行う。
→ルートURLへリダイレクトされることを検証する。
→(8)そして今度は有効なユーザーへ切り替える。
→メールアドレスは有効だがトークンが無効なまま、パスワード再設定フォームヘGETリクエストを送信させる記述を行う。
→ルートURLへリダイレクトされることを検証する。
→(9)有効なメールアドレスとトークンで、パスワード再設定フォームヘGETリクエストを送信させる記述を行う。
→ちゃんとパスワード再設定ビューテンプレートが描画されていることを検証する。
→メールアドレス用の隠しフィールドセレクタのvalue値に、取得したユーザーのメールアドレスが表示されていることを検証する。
→(10)無効なパスワードとパスワード確認を、PasswordResetsコントローラのupdateアクションヘPATCHリクエストを送信してしまった場合の記述をする。
→エラーメッセージセレクタが表示されていることを検証する。
→(11)パスワードとパスワード確認入力が空のまま、PasswordResetsコントローラのupdateアクションヘPATCHリクエストを送信してしまった場合の記述をする。
→この場合もエラーメッセージセレクタが表示されていることを検証する。
→(12)有効なパスワードとパスワード確認入力で、PasswordResetsコントローラのupdateアクションヘPATCHリクエストが送信された場合の記述をする。
→ログインユーザーが存在することを検証する。
→フラッシュが表示されていることを確認する。
→そのユーザーのページへ、ちゃんとリダイレクトされているかどうかを検証する。
➡︎テストスイートGREEN。
test/integration/password_resets_test.rbrequire 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest #(1)setupメソッドを定義する。 def setup ActionMailer::Base.deliveries.clear @user = users(:テスト用のユーザー) end #(2)パスワード再設定のテストを行う。 test "password resets" do get new_password_reset_path assert_template 'password_resets/new' #(3)まずは無効なメールアドレスで、パスワード再設定用のメール送信をPasswordResetsリソースのcreateアクションへPOSTリクエストを送信させる記述を行う post password_resets_path, params: { password_reset: { email: "無効なメアド" } } assert_not flash.empty? assert_template 'password_resets/new' #(4)今度は有効なメールアドレスで、パスワード再設定用のメール送信をPasswordResetsリソースのcreateアクションへPOSTリクエストを送信させる記述を行う。 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 #(5)パスワード再設定フォームのテストを行う。 user = assigns(:user) #(6)無効なメールアドレスで、パスワード再設定フォームへGETリクエストを送信させる記述を行う。 get edit_password_reset_path(user.reset_token, email: "無効なメアド") assert_redirected_to root_url #(7)ここでユーザーを無効なユーザーに切り替える処理を記述(攻撃者によるアカウント乗っ取りのテスト)。 user.toggle!(:activated) get edit_password_reset_path(user.reset_token, email: user.email) assert_redirected_to root_url #(8)そして今度は有効なユーザーで(トークンは無効)。 get edit_password_reset_path('無効なトークン', email: user.email) assert_redirected_to root_url #(9)有効なメールアドレスとトークンで、パスワード再設定フォームヘGETリクエストを送信させる記述を行う。 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 #(10)無効なパスワードとパスワード確認を、PasswordResetsコントローラのupdateアクションヘPATCHリクエストを送信してしまった場合の記述をする。 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "無効なパス", password_confirmation: "無効なパス確認" } } assert_select 'div#error_explanation' #(11)パスワードとパスワード確認入力が空のまま、PasswordResetsコントローラのupdateアクションヘPATCHリクエストを送信してしまった場合の記述をする。 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "", password_confirmation: "" } } assert_select 'div#error_explanation' #(12)有効なパスワードとパスワード確認入力で、PasswordResetsコントローラのupdateアクションヘPATCHリクエストが送信された場合の記述をする。 patch password_reset_path(user.reset_token), params: { email: user.email, user: { password: "有効なパス", password_confirmation: "有効なパス確認" } } assert is_logged_in? assert_not flash.empty? assert_redirected_to user end end
最後に
以上で後編のパスワード再設定機能の本実装が完了しました。
前編→パスワード再設定用のリソース作成
中編→パスワード再設定メール送信機能の実装