T_Y_CODE

プログラミング学習記録

学習記録 21日目

21日目の学習記録をまとめていきます。

学習計画

学習内容

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

  • パスワードの再設定機能を実装していきます。
12.1 PasswordResetsリソース
  • トピックブランチを作成します。
$ git checkout -b password-reset
12.1.1 PasswordResetsコントローラ
  • パスワード再設定用のコントローラを作成します。ビューはnew, editを作成します。コントローラに対するテストは作成しないようにします。後に統合テストでテストします。
$ rails g controller PasswordRsets new edit --no-test-framework
  • ルーティングを設定します。必要なアクションはnew, crete, edit, updateです。
# config/routes.rb
Rails.application.routes.draw do
  #...
  resources :password_resets, only: [:new, :crete, :edit, :update]
end
  • ログインビューにパスワード再設定画面へのリンクを追記します。
# app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(url: login_path, scope: :session, local: true) do |f| %>
      #...
      <%= f.label :password %>
      <%= link_to "(forgot password)", new_password_reset_path %>
      <%= f.password_field :password, class: 'form-control'%>
      #...
    <% end %>
    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>
演習

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

$ rails t
45 tests, 196 assertions, 0 failures, 0 errors, 0 skips

2. 表 12.1の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。ヒント: アカウント有効化で行った演習(11.1.1.1)と同じ理由です。

  • パスワードリセット用のURLを生成しメイラーでユーザに送付するため。
12.1.2 新しいパスワードの設定
  • 11章同様トークンを作成してハッシュ化した文字列をデータベース上に保存、メールでトークンを送りトークンとハッシュ化した文字列の認証を行います。Usersテーブルにreset_digestを追加します。
$ rails g migration add_reset_to_users reset_digest:string reset_sent_at:datetime
# db/migrate/***_add_reset_to_users.rb
class AddResetToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :reset_digest, :string
    add_column :users, :reset_sent_at, :datetime
  end
end
  • パスワード再生設定画面のビューを作成します。
# app/views/password_resets/new.html.erb
<%= provide(:title, "Forgot password") %>
<h1>Forgot password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(url: password_resets_path, scope: :password_reset, local: true) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>
      <%= f.submit "Submit", class: 'btn btn-primary' %>
    <% end %>
  </div>
</div>
  • ビューの動作確認します。

f:id:t_y_code:20201027121826p:plain

演習

1. リスト 12.4のform_withメソッドで、@password_resetではなく:password_resetを使っている理由を考えてみましょう。

  • ActiveRecordオブジェクトと連携させる場合は@~を使用する。(モデルを渡している) メリットはエラーメッセージを受け取れたりnewやeditで共通のコードが使用出来たり、urlを指定しなくてもRailsが自動で認識してくれること。一方、sessionやpassword_restはActiveRecordオブジェクトではないため先ほどのメリットは受けられず、またURLの指定が必要なためscope: :password_resetと表記している。
12.1.3 createアクションでパスワード再設定
  • createアクションを書いていきます。
# app/controllers/password_reset_controller.rb
class PasswordResetsController < ApplicationController
  #...

  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @user.create_reset_digest
      @user.send_password_reset_email
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash[:danger] = "Email address not found"
      render 'new'
    end
  end

  #...
end
  • Userモデルにcreate_reset_digestメソッドとsend_password_reset_emailメソッドを実装します。
# app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token
  #...

  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)
  end

  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  #...
end
演習

1. 試しに有効なメールアドレスをフォームから送信してみましょう(図 12.6)。どんなエラーメッセージが表示されたでしょうか?

  • 「wrong number of arguments (given 1, expected 0)」 引数の数が合わない。

2. コンソールに移り、先ほどの演習課題で送信した結果、(エラーと表示されてはいるものの)該当するuserオブジェクトにはreset_digestとreset_sent_atがあることを確認してみましょう。また、それぞれの値はどのようになっていますか?

  • 私の環境ではreset_digest, reset_sent_atには値は入りませんでした。エラーになる@user.send_password_reset_emailをコメントアウトすると値が入るようになりました。
12.2.1 パスワード再設定のメールとテンプレート
  • まずUserMailerの設定をします。
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer

  #...

  def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end
end
  • メールのビューをそれぞれ実装します。再設定用のURLはedit_password_reset_url(@user.reset_token, email: @user.email)となる。
  • プレビューの設定をしてプレビューの確認をしてみます。
# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview

  #...

  def password_reset
    user = User.first
    user.reset_token = User.new_token
    UserMailer.password_reset(user)
  end
end

f:id:t_y_code:20201027130134p:plain

演習

1. ブラウザから、送信メールのプレビューをしてみましょう。「Date」の欄にはどんな情報が表示されているでしょうか?

  • 現在時刻

2. パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。

Content-Transfer-Encoding: 7bit

To reset your password click the link below:

http://localhost:3000/password_resets/ONmgXmkAlhw-vpVaax7LXA/edit?email=example%40railstutorial.org

This link will expire in two hours.

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

3. コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認してみましょう。

$ rails c
> user = User.first
> user.reset_digest
# =>"$2a$12$JVjlIqurccLXexZHuXPcqeJKqHbN4N7d1e7/VRCdZOKghJf.tFIrS"
> user.reset_sent_at
# => Tue, 27 Oct 2020 04:03:33 UTC +00:00
12.2.2 送信メールのテスト
  • メイラーメソッドのテストを作成します。
# test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase
  #...

  test "password_reset" do
    user = users(:michael)
    user.reset_token = User.new_token
    mail = UserMailer.password_reset(user)
    assert_equal "Password reset", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.reset_token, mail.body.encoded
    assert_match CGI.escape(user.email), mail.body.encoded
  end
end
  • テストが成功することを確認します。
$ rails t
46 tests, 203 assertions, 0 failures, 0 errors, 0 skips
演習

1. メイラーのテストだけを実行してみてください。このテストは green になっているでしょうか?

$ rails test:mailers
2 tests, 16 assertions, 0 failures, 0 errors, 0 skips

2. リスト 12.12にある2つ目のCGI.escapeを削除すると、テストが red になることを確認してみましょう。

  • 確認のみのため記載省略
12.3.1 editアクションで再設定
  • editビューを作成します。editビューではパスワードの再設定をするだけなのでパスワードの入力フォームしか必要ありません。しかしeditアクションの後に実行するupdateアクションにemailの情報もPOSTしたいです。なのでhidden_field_tagメソッドを利用してemailをupdateアクションにPOST出来るようにしておきます。
# app/views/password_resets/edit.html.erb
<%= provide(:title, "Reset password") %>
<h1>Reset password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, url: password_reset_path(params[:id]), local: true) 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>
  • edit, updateアクションを実行する前にユーザが正当なユーザであるか(ユーザが存在する, 有効化されている, 認証済みである)を確認します。
# app/controllers/password_resets_controller.rb
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
演習

1. 12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて、図 12.11のように表示されるか確かめてみましょう。
f:id:t_y_code:20201027132927p:plain
2. 先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?

  • まだupdateアクションを記載してないのでエラーになる。
12.3.2 パスワードを更新する
  • まずeditアクション, updateアクションを行う前にパスワード変更期限が切れてないかのチェックを行うようにします。
# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user, only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]
  before_action :check_expiration, only: [:edit, :update]

  #...

  private
    #...

    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
end
  • Userモデルにpassword_reset_expired?メソッドを追加します。
# app/models/user.rb
class User < ApplicationRecord
  #...

  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  #...
end
  • updateアクションを実装します。
# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  #...

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

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

    #...
end
演習

1. 12.2.1.1で得られたリンク(Railsサーバーのログから取得)をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?
f:id:t_y_code:20201027140252p:plain
2. コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう(図 12.13)。パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。ヒント: 新しい値はuser.reloadを通して取得する必要があります。

$ rails c
> user = User.first
> user.password_digest
# => "$2a$12$SOF.V9RvP6InqrHJYWxKOOct1UHPe7udMa51fYpm5vmAsAyH337rO"
# パスワードを変更後
> user.reload.password_digest
# => "$2a$12$Ns676msyVVpQkdDKuCka3e3DkXdTaDLKMg5uATAwn//UDbOhNWC8i"
12.3.3 パスワードの再設定をテストする
  • パスワードの再設定の統合テストを作成していきます。
$ rails g integration_test password_resets
# test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest
  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end

  test "password resets" do
    get new_password_reset_path
    assert_template 'password_resets/new'
    assert_select 'input[name=?]', 'password_reset[email]'
    # メールアドレスが無効
    post password_resets_path, params: { password_reset: { email: "" } }
    assert_not flash.empty?
    assert_template 'password_resets/new'
    # メールアドレスが有効
    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: "foobaz",
                        password_confirmation: "barquux" } }
    assert_select 'div#error_explanation'
    # パスワードが空
    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: "foobar",
                        password_confirmation: "foobar" } }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
  end
end
  • テストが成功するか確認します。
$ rails t
47 tests, 222 assertions, 0 failures, 0 errors, 0 skips
演習

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

# app/models/user.rb
class User < ApplicationRecord
  #...

  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

  #...
end

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

# app/models/user.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest
  #...

  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
end

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

# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  #...

  def update
    if params[:user][:password].empty?
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update(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

  #...
end

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

# test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest
  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end

  test "password resets" do
    #...
    # 有効なパスワード
    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
    assert_nil user.reload.reset_digest
  end
end
$ rails t
48 tests, 226 assertions, 0 failures, 0 errors, 0 skips
12.4 本番環境でのメール送信(再掲)
  • リモートリポジトリにプッシュしherokuにデプロイします。
$ rails t
$ git add -A
$ git commit -m "Add password reset"
$ git push origin password-reset
$ git checkout master
$ git merge password-reset
$ git push origin master
$ git push heroku
$ heroku run rails db:migrate
演習

1. production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

  • SendGridアカウントが凍結されているためheroku logsでメールの内容を確認します。

2. メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。ヒント: ターミナルからheroku logsコマンドを実行してみましょう。
f:id:t_y_code:20201027144851p:plain

$ heroku logs
...
Started GET "/account_activations/nW7w2EIzu22TKvZ4i7_CMQ/edit?email=test%40test.com"
Processing by AccountActivationsController#edit as HTML
  Parameters: {"email"=>"test@test.com", "id"=>"nW7w2EIzu22TKvZ4i7_CMQ"}
  User Load (2.3ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "test@test.com"], ["LIMIT", 1]]
  User Update (3.8ms)  UPDATE "users" SET "activated" = $1, "activated_at" = $2 WHERE "users"."id" = $3  [["activated", true], ["activated_at", "2020-10-27 05:48:30.811183"], ["id", 102]]
...

3. アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正しくパスワードの再設定ができたでしょうか?

  • 動作確認しました。

f:id:t_y_code:20201027145630p:plain

  • 以上で12章は終了です。