はじめに
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]から取得
- hiddenフィールドに保持(
<% 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にアクセスすると、 パスワード再設定画面が表示されることを確認。
演習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
動作確認
ブラウザを起動し、パスワード再設定を行えることを確認。
演習1
12.2.1.1で得られたリンク (Railsサーバーのログから取得) をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?
パスワード
演習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"
パスワードの再設定を行う。
コンソールからリロードして、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
の場合、メールを保存する配列をクリアする- 参考情報:Action Mailer の基礎
- 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_reset_path)
# メールアドレスが有効 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
の有効化フラグを逆転- toggleでbooleanを逆転できる
- 参考:Railsのいろんなメソッド
- 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
- email:
- 画面の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:ブランク
- email:
- 画面の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
- email:
- ログイン状態であることを確認
- フラッシュメッセージが空ではないこと(成功メッセージが表示)
- ユーザープロフィール画面へリダイレクトする
# 有効なパスワードとパスワード確認 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
- password_reset
- params
- パスワード再設定フォームのテストのために、アクションを実行結果、インスタンス変数に代入されたオブジェクトを取得
- @userにassigns(:user)の結果を代入
- パスワード再設定メール送信時間を3時間前に更新する
- patchリクエストを送信する。(password_reset_path(@user.reset_token))
- params
- password_reset
- メールアドレス:@user.email
- user
- パスワード:foobar
- 確認用パスワード:foobar
- password_reset
- params
- レスポンスが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が出る場合の対処法)
こういう失敗を糧に、熟練を目指していきます!