0
0

More than 3 years have passed since last update.

Railsチュートリアル 第12章 パスワードの再設定 - パスワードを再設定する

Posted at

ここまでに実装してきたものと、次に何を実装するか

ここまでの実装により、PasswordResetsコントローラーのnewアクションおよびcreateアクションに対する実装が完成しました。次に必要となるのは、同じくPasswordResetsコントローラーの、editアクションおよびupdateアクションに対する実装です。

editアクションで再設定

長くなりましたので、別記事で解説します。

演習 - editアクションで再設定

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

まず、有効なユーザーのメールアドレスであるexample-2@railstutorial.orgを対象に、パスワード再設定用メールの送信を求める操作をしてみましょう。

スクリーンショット 2019-12-14 15.09.08.png

この画面で「Submit」ボタンをクリックすると、パスワード再設定用URLを含むメールを送信する処理がされます。Railsサーバーのログには、以下の記録があります。

Sent mail to example-2@railstutorial.org (8.3ms)
Date: Sun, 15 Dec 2019 07:58:01 +0000
From: noreply@example.com
To: example-2@railstutorial.org
Message-ID: <5df5e7895d0be_1c62ac8ce7605cc87241@705320d4d96d.mail>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
...略

メール本文中に記載されたパスワード再設定用URLは以下のとおりです。

https://localhost:3000/password_resets/D48WcYU6q-K2SHWqjYsrEA/edit?email=example-2%40railstutorial.org

私の環境では、Railsが動作しているのはDocker上であり、Docker環境のTCPポート番号3000はローカル環境のポート番号8080に紐付けされています。また、URLスキームが https:// ではアクセスできず、http:// でアクセスする必要があります。そのため、ローカルからアクセスする場合、アクセス先のURLは以下となります。

http://localhost:8080/password_resets/D48WcYU6q-K2SHWqjYsrEA/edit?email=example-2%40railstutorial.org

実際に上述URLにアクセスすると、Webブラウザには以下のような画面が出力されます。

スクリーンショット 2019-12-15 17.09.49.png

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

スクリーンショット 2019-12-15 17.12.27.png

上記のように、「Unknown action」というエラー画面が表示されます。

Railsサーバーのログには、以下の記録があります。

Started PATCH "/password_resets/D48WcYU6q-K2SHWqjYsrEA" for 172.17.0.1 at 2019-12-15 08:12:04 +0000
Cannot render console from 172.17.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255

AbstractController::ActionNotFound (The action 'update' could not be found for PasswordResetsController):
...スタックトレース略

現時点でPasswordResetsコントローラーに対するupdateアクションが実装されていないためのエラーですね。

パスワードを更新する

長くなりましたので、別記事で解説します。

演習 - パスワードを更新する

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

まずは、パスワード再設定用のフォームを表示させます。

スクリーンショット 2019-12-16 12.24.53.png

passwordに「foobaz」、confirmationに「barquux」と入力して「Submit」ボタンを押してみます。

スクリーンショット 2019-12-16 12.25.02.png

「The form contains 1 error. Password confirmation doesn't match Password」というエラーメッセージが表示されています。

サーバーログには以下のように表示されています。

Started PATCH "/password_resets/IlMln7IBh63Pd0zjHzgWmw" ...略
Processing by PasswordResetsController#update as HTML
  Parameters: {...略 "email"=>"example-2@railstutorial.org", "user"=>{"password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Update password", "id"=>"IlMln7IBh63Pd0zjHzgWmw"}
  User Load (8.2ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "example-2@railstutorial.org"], ["LIMIT", 1]]
   (0.1ms)  begin transaction
  User Exists (2.1ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND ("users"."id" != ?) LIMIT ?  [["email", "example-2@railstutorial.org"], ["id", 3], ["LIMIT", 1]]
   (0.1ms)  rollback transaction
  ...略
Completed 200 OK in 660ms (Views: 458.4ms | ActiveRecord: 10.5ms)

「rollback transaction」とありますね。確かにRDBに対する更新が取り消されています。

2.1. コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。

example-2@railstutorial.orgというメールアドレスを持つユーザーのパスワード再設定を送信した」という前提です。

# rails console --sandbox
>> user = User.find_by(email: "example-2@railstutorial.org")
>> user.password_digest
=> "$2a$10$lVjDouNtJJObQKOU4uSrtuTeU9/rPu2MkrSyfQr.NETDUXEKyxDJq"

2.2. 次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう (図 12.13)。パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。

ヒント: 新しい値はuser.reloadを通して取得する必要があります。

Railsコンソールをサンドボックスモードで起動した場合、RDBの変更を伴うリクエストを送出する前に一旦Railsコンソールを抜けなければなりません。そうでないと、RDBの変更を伴うリクエストを受け取った時点でエラーが返ってしまいます。サンドボックスモードのRailsコンソールはRDBをロックするためです。

# rails console --sandbox
>> user = User.find_by(email: "example-2@railstutorial.org")
>> user.password_digest
=> "$2a$10$IOSmbIOqlca85aXRmwMqpuc53.KSSnOInMEyRqSAtDmkvNwcxUGU2"
$2a$10$lVjDouNtJJObQKOU4uSrtuTeU9/rPu2MkrSyfQr.NETDUXEKyxDJq
$2a$10$IOSmbIOqlca85aXRmwMqpuc53.KSSnOInMEyRqSAtDmkvNwcxUGU2

確かにpassword_digestの値は変化しています。

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

ここまでの実装が完了すると、test/integration/password_resets_test.rbの全体像は以下のようになります。

test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:rhakurei)
  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'
    get new_password_reset_path
    assert flash.empty?
    # メールアドレスが有効
    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
    # パスワードが空
    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: "barquux" } }
    assert_select 'div#error_explanation'
    # 短すぎるパスワード
    patch password_reset_path(user.reset_token),
            params: { email:user.email,
                      user: { password:              "foo",
                              password_confirmation: "foo" } }
    assert_select 'div#error_explanation'
    # パスワード再設定トークンが期限切れ
    user.reset_sent_at = 48.hours.ago
    user.update_attribute(:reset_sent_at, user.reset_sent_at)
    patch password_reset_path(user.reset_token),
            params: { email:user.email,
                      user: { password:              "foobar",
                              password_confirmation: "foobar" } }
    assert_not is_logged_in?
    assert_not flash.empty?
    assert_redirected_to new_password_reset_url
    # 有効なパスワードとパスワード確認
    user.reset_sent_at = Time.zone.now
    user.update_attribute(:reset_sent_at, user.reset_sent_at)
    patch password_reset_path(user.reset_token),
            params: { email:user.email,
                      user: { password:              "foobar",
                              password_confirmation: "foobar" } }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
  end
end

このテストが成功することを確認しておきましょう。

# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1070
Started with run options --seed 8172

  1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.99857s
1 tests, 23 assertions, 0 failures, 0 errors, 0 skips

テストスイート全体が成功することも確認しておきます。

# rails test
Running via Spring preloader in process 1083
Started with run options --seed 64812

  48/48: [=================================] 100% Time: 00:00:06, Time: 00:00:06

Finished in 6.42649s
48 tests, 228 assertions, 0 failures, 0 errors, 0 skips

演習 - パスワードの再設定をテストする

演習に臨む前に、上述のテストコードの一部を削除する必要があります。

test/integration/password_resets_test.rb
  require 'test_helper'

  class PasswordResetsTest < ActionDispatch::IntegrationTest

    def setup
      ActionMailer::Base.deliveries.clear
      @user = users(:rhakurei)
    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'
      get new_password_reset_path
      assert flash.empty?
      # メールアドレスが有効
      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
      # パスワードが空
      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: "barquux" } }
      assert_select 'div#error_explanation'
      # 短すぎるパスワード
      patch password_reset_path(user.reset_token),
              params: { email:user.email,
                        user: { password:              "foo",
                                password_confirmation: "foo" } }
      assert_select 'div#error_explanation'
-     # パスワード再設定トークンが期限切れ
-     user.reset_sent_at = 48.hours.ago
-     user.update_attribute(:reset_sent_at, user.reset_sent_at)
-     patch password_reset_path(user.reset_token),
-             params: { email:user.email,
-                       user: { password:              "foobar",
-                               password_confirmation: "foobar" } }
-     assert_not is_logged_in?
-     assert_not flash.empty?
-     assert_redirected_to new_password_reset_url
      # 有効なパスワードとパスワード確認
      user.reset_sent_at = Time.zone.now
      user.update_attribute(:reset_sent_at, user.reset_sent_at)
      patch password_reset_path(user.reset_token),
              params: { email:user.email,
                        user: { password:              "foobar",
                                password_confirmation: "foobar" } }
      assert is_logged_in?
      assert_not flash.empty?
      assert_redirected_to user
    end
  end

1.1. リスト 12.20に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。

リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。

ちなみにリスト 12.20にあるコードには、前章の演習(リスト 11.39)の解答も含まれています。

app/models/user.rbを以下のように変更していきます。「前章の演習(リスト 11.39)の解答」というのは、activateメソッドのことですね。

app/models/user.rb
  class User < ApplicationRecord
    attr_accessor :remember_token, :activation_token, :reset_token
    ...略

    # アカウントを有効にする
    def activate
      update_columns(activated: true, activated_at: Time.zone.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(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now)
    end

    ...略

    private

      ...略
  end

1.2. また、変更後にテストを実行し、greenになることも確認してください。

必要最低限のテストを実行します。

# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1109
Started with run options --seed 19282

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

Finished in 3.49273s
1 tests, 23 assertions, 0 failures, 0 errors, 0 skips

2. リスト 12.21のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐 (リスト 12.16) を統合テストで網羅してみましょう。

期限切れをテストする方法はいくつかありますが、リスト 12.21でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます (なお、大文字と小文字は区別されません)。

(リスト 12.21のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです)

テストコードそのものは以下のようになります。「有効なパスワードとパスワード確認」とコードの構成は似ていますが、@useruserを取り違えるミスに注意が必要です。

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
end
test/integration/password_resets_test.rb
  require 'test_helper'

  class PasswordResetsTest < ActionDispatch::IntegrationTest

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

    test "password resets" do
      ...略
    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
+   end

  end

2.2. (追加演習)変更後にテストを実行し、greenになることも確認してください。

私の環境では、以下の行がtest/integration/password_resets_test.rbの72行目となります。

test/integration/password_resets_test.rb(72行目)
test "expired token" do

上記を踏まえて、対応するテストを実行してみましょう。

# rails test test/integration/password_resets_test.rb:72
Running via Spring preloader in process 1148
Started with run options --seed 28814

  2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.95873s
1 tests, 3 assertions, 0 failures, 0 errors, 0 skips

途中でtypo等がなければ、テストは上記のように成功するはずです。

同様のアプローチで、PasswordResetsコントローラーに対するここまでのテストすべてを「1つのテストで1つの機能に対してテストを行うようにする」というリファクタリングもできそうです。

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

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

PasswordResetsController#updateの内容を、以下のように変更します。

PasswordResetsController#update
  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

4. リスト 12.18に1行追加し、1つ前の演習課題に対するテストを書いてみましょう。

ヒント: リスト 9.25assert_nilメソッドとリスト 11.33user.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。

対応するテストの内容は以下のようになります。

assert_nil user.reload.reset_digest

正しい対象に対してテストが行われていることの確認

PasswordResetsController#updateの内容が以下である場合、テストは失敗します。

PasswordResetsController#update
def update
  if params[:user][:password].empty?
    @user.errors.add(:password, :blank)
    render 'edit'
  elsif @user.update_attributes(user_params)
    log_in @user
    flash[:success] = "Password has been reset."
    redirect_to @user
  else
    render 'edit'
  end
end

失敗の内容は以下です。

# rails test test/integration/password_resets_test.rb   
Running via Spring preloader in process 1161
Started with run options --seed 17444

 FAIL["test_password_resets", PasswordResetsTest, 3.2027707000015653]
 test_password_resets#PasswordResetsTest (3.20s)
        Expected "$2a$04$VSxeyLa4LwjS2fyhIBYe2.flQQgt9j2TrpgleMOexHd/j1Y63b6I2" to be nil.
        test/integration/password_resets_test.rb:68:in `block in <class:PasswordResetsTest>'

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

Finished in 3.20832s
2 tests, 21 assertions, 1 failures, 0 errors, 0 skips

私の環境では、test/integration/password_resets_test.rbの68行目は、現時点で以下の内容となっています。

test/integration/password_resets_test.rb(68行目)
assert_nil user.reload.reset_digest

テスト失敗時のメッセージの内容は以下です。

Expected "$2a$04$VSxeyLa4LwjS2fyhIBYe2.flQQgt9j2TrpgleMOexHd/j1Y63b6I2" to be nil.

reset_digestnilでない」ということですね。確かに正しい対象に対してテストが行われているようです。

正しい実装をすればテストが成功することの確認

PasswordResetsController#updateの内容を、以下のように変更します。

PasswordResetsController#update
  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

再びテストを実行していきましょう。

# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1188
Started with run options --seed 40656

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

Finished in 3.84093s
2 tests, 24 assertions, 0 failures, 0 errors, 0 skips

今度こそテストが成功しました。

# rails test
Running via Spring preloader in process 1201
Started with run options --seed 49326

  49/49: [=================================] 100% Time: 00:00:07, Time: 00:00:07

Finished in 7.45029s
49 tests, 229 assertions, 0 failures, 0 errors, 0 skips

上述のように、全体のテストも成功することを確認しておきましょう。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0