0
0

More than 3 years have passed since last update.

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

Posted at

PasswordResetsコントローラー

PasswordResetsコントローラーの生成

例によって、rails generate controllerコマンドでPasswordResetsコントローラーを生成していきます。

# rails generate controller PasswordResets new edit --no-test-framework
Running via Spring preloader in process 14754
      create  app/controllers/password_resets_controller.rb
       route  get 'password_resets/edit'
       route  get 'password_resets/new'
      invoke  erb
      create    app/views/password_resets
      create    app/views/password_resets/new.html.erb
      create    app/views/password_resets/edit.html.erb
      invoke  helper
      create    app/helpers/password_resets_helper.rb
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/password_resets.coffee
      invoke    scss
      create      app/assets/stylesheets/password_resets.scss

特筆すべき点は以下です。

  • アクションの自動生成を行っている
  • テストの生成を行っていない

アクションの自動生成

第11章で扱った「ユーザーの有効化」とは異なり、今回はビューも扱います。そのため、第11章では行わなかった「rails generate controllerコマンドにおけるアクションの自動生成」を行います。対象はneweditの両アクションです。

テストを生成していない

rails generate controllerコマンドにおいて、--no-test-frameworkというオプションは、「テストを生成しない」という意味のオプションです。テストを生成しない理由は、今回実装するPasswordResetsコントローラーにおける、「コントローラーの単体テストは実装せず、統合テストのみでカバーする」という方針に基づくものです。

パスワード再設定用リソースに関するルーティングの定義

「ビューを必要とする」ということは、「リソースにアクセスするためのURLが必要となる」ということでもあります。そうしたURLを定義するのは、config/routes.rbにおけるルーティング定義でしたね。早速、ルーティングを定義していきましょう。

config/routes.rb
  Rails.application.routes.draw do
    get 'password_resets/new'

    get 'password_resets/edit'

    root    'static_pages#home'
    get     '/help',    to: 'static_pages#help'
    get     '/about',   to: 'static_pages#about'
    get     '/contact', to: 'static_pages#contact'
    get     '/signup',  to: 'users#new'
    post    '/signup',  to: 'users#create'
    get     '/login',   to: 'sessions#new'
    post    '/login',   to: 'sessions#create'
    delete  '/logout',  to: 'sessions#destroy'
    resources :users
    resources :account_activations, only: [:edit]
+   resources :password_resets, only: [:new, :create, :edit, :update]
  end

PasswordResetsリソースで必要となるルーティングは、newedit、およびそれぞれに対してRDBに変更を反映するcreateupdate、以上の4つとなります。

HTTPリクエスト URL Action 名前つきルート
GET /password_resets/new new new_password_reset_path
POST /password_resets create password_resets_path
GET /password_resets/<token>/edit edit edit_password_reset_url(token)
PATCH /password_resets/<token> update password_reset_url(token)

editおよびupdateについては、「メールに記載されたURLへのアクセスをトリガーとする」というのがポイントです。このような用法においては、_pathではなくて_urlを使うのでしたね。

ログイン画面のビューの実装変更

まずはじめに、ログイン画面のビューにパスワード再設定用のリンクを追加します。

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_for(:session, url: login_path) do |f| %>

        <%= f.label :email %>
        <%= f.email_field :email, class: 'form-control' %>

        <%= f.label :password %>
+       <%= link_to "(forgot_password)", new_password_reset_path %>
        <%= f.password_field :password, class: 'form-control' %>

        <%= f.label :remember_me, class: "checkbox inline" do %>
          <%= f.check_box :remember_me %>
          <span>Remember me on this computer</span>
        <% end %>

        <%= f.submit "Log in", class: "btn btn-primary" %>
      <% end %>

      <p>New user? <%= link_to "Sign up now!", signup_path %></p>
    </div>
  </div>

当該リンクを追加した後、ログイン画面は以下のようになります。既に「forgot password」と表示されたリンクが追加されていますね。

スクリーンショット 2019-12-11 18.21.43.png

但し、現時点でPasswordResetsのnewには何の動作も定義していないため、初期状態のapp/views/password_resets/new.html.erbを描画した結果が返ってくるだけです。

演習 - PasswordResetsコントローラー

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

# rails test
Running via Spring preloader in process 14829
Started with run options --seed 46822

  46/46: [=================================] 100% Time: 00:00:11, Time: 00:00:11

Finished in 11.83135s
46 tests, 197 assertions, 0 failures, 0 errors, 0 skips

1.発展. ログイン画面に、パスワード再設定用のリンクが存在することに対するテストを実装してみましょう。

長くなりましたので、以下の記事に。

2. 表 12.1の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。

ヒント: アカウント有効化で行った演習 (11.1.1.1) と同じ理由です。

項目「パスワード再設定用リソースに関するルーティングの定義」の最後に記述しました。

新しいパスワードの設定

アカウント有効化処理との類似点と相違点

アカウント有効化処理との類似点

実装の大枠は、11章で行った「アカウント有効化」と類似しています。すなわち、「トークンを含むURLをメールで送信する。当該トークンに対応するダイジェストをRDBに保存する。トークンを含むURLにアクセスされたら、対応する処理を開始する。」という処理の流れについては、アカウント有効化もパスワード再設定も同じです。

アカウント有効化処理との相違点

一方で、パスワード再設定においては、アカウント有効化では考慮する必要のなかった事柄の一つを考慮する必要があります。それは、「パスワード再設定用のリンクには有効期限を設定する」という事柄です。「何らかの理由でパスワード再設定用のリンクが放置された場合に、第三者によるリンクの悪用リスクを下げる」という意味で必要となってきます。

Userモデルに、パスワード再設定に必要な属性を追加する

Userモデルに新たに必要となる属性

Userモデルに新たに必要となる属性は以下の2つです。

  • パスワード再設定用トークンに対するダイジェスト
  • パスワード再設定メールの送信時刻

パスワード再設定用リンクの有効期限は、「パスワード再設定メールの送信時刻から○時間後」という形で設定していきます。

新たなUserモデルの内容

新たに必要となる属性は、以下の名前とします。

  • パスワード再設定用トークンに対するダイジェスト…reset_digest
  • パスワード再設定メールの送信時刻…reset_sent_at

上記を踏まえた上で、新たなUserモデルの内容を図にすると、以下のようになります。

User_full.png

新たなUserモデルの内容をRDBに反映する

Userモデルに追加する属性に対するマイグレーションを生成する

マイグレーションそのものの名前はadd_reset_to_usersとします。

# rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
Running via Spring preloader in process 14894
      invoke  active_record
      create    db/migrate/[timestamp]_add_reset_to_users.rb

以下のマイグレーションが生成されました。クラス名はAddResetToUsersとなっています。

db/migrate/[timestamp]_add_reset_to_users.rb
class AddResetToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :reset_digest, :string
    add_column :users, :reset_sent_at, :datetime
  end
end

いずれも初期値はnilとするので、生成されたマイグレーションに手を付ける必要はありません。

生成したマイグレーションをRDBに反映する

生成したマイグレーションは、いつものように、rails db:migrateコマンドによってRDBに反映します。

# rails db:migrate
== [timestamp] AddResetToUsers: migrating ==================================
-- add_column(:users, :reset_digest, :string)
   -> 0.0288s
-- add_column(:users, :reset_sent_at, :datetime)
   -> 0.0033s
== [timestamp] AddResetToUsers: migrated (0.0334s) =========================

パスワード再設定メールの送信用フォーム

フォームの内容そのものは、ログインフォーム(app/views/sessions/new.html.erb)に類似するものとなります。以下はapp/views/sessions/new.html.erbの内容です。

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_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= link_to "(forgot password)", new_password_reset_path %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

一方で、ログイン用のフォームとはいくつかの相違点もあります。パスワード再設定メール送信用のフォーム側から見た大きな違いを以下に記述していきます(ほかにも小さな違いはいくつかあります)。

  • (当然ながら)ビューの場所が異なる
    • app/views/sessions/new.html.erbではなく、app/views/password_resets/new.html.erbとなる
  • form_forで扱うリソースとURLが異なる
    • SessionsController#createではなく、PasswordResetsController#createとなる
  • パスワードの入力が省略されている
app/views/password_resets/new.html.erb
<% provide(:title, "Password reset") %>
<h1>Password reset</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:password_reset, url: password_resets_path) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.submit "Submit", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

この時点で、パスワード再設定メール送信用のHTMLフォームは正しく表示されるようになっています。ただ、「Submit」ボタンを押したときの動作はまだ定義されていません。これからcreateアクションで定義していくことになります。

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

演習

1. リスト 12.4のform_forメソッドでは、なぜ@password_resetではなく:password_resetを使っているのでしょうか? 考えてみてください。

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

createアクションでパスワード再設定

パスワード再設定フォームのSubmitボタンが押された後の動作となります。実装が必要となるのは、以下の処理です。

  • メールアドレスをキーとしてユーザーをRDBから検索する
  • 当該ユーザーに対する以下の処理
    • パスワード再設定用トークンを発行し、対応するパスワード再設定用ダイジェストでRDBを更新する
    • パスワード再設定用トークンを発行した日時をRDBに保存する
  • フラッシュメッセージを定義した上で、ルートURLにリダイレクト
  • メールアドレスが無効な場合の処理
    • フラッシュメッセージを定義した上で、パスワード再設定メールの送信用フォームにリダイレクト

パスワード再設定の統合テスト

Railsチュートリアル本文とは少し順番を捻じ曲げ、パスワード再設定の統合テストを先に生成していきます。

統合テストの生成

統合テストなので、使用するコマンドはrails generate integration_testです。名前はpassword_resetsとします。

# rails generate integration_test password_resets
Running via Spring preloader in process 44
      invoke  test_unit
      create    test/integration/password_resets_test.rb

テストの初期設定

Railsチュートリアル本文の通りに実装していくとすれば、テストの初期設定は以下のようになります。

  • 初期化処理
    • テストメールの送信状態を初期化する
    • 今後のテスト内で使う@user変数の内容を定義する
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

end

メールアドレスが無効な場合の処理

テスト駆動で実装してみました。その顛末は別記事にて。

メールアドレスが有効な場合の処理

こちらもテスト駆動で実装してみました。その顛末は別記事にて。

演習 - createアクションでパスワード再設定

演習に取り組む環境を整えるために、「メールの送信処理の実装」プロセスを「send_password_reset_emailメソッドの実装」まで完了した段階で一旦中断し、以下のコードを実装します。

app/controllers/password_resets_controller.rb
  class PasswordResetsController < ApplicationController
    def new
    end

    def create
      @user = User.find_by(email: params[:password_reset][:email].downcase)
      if @user
        @user.create_reset_digest
        @user.send_password_reset_email
-       #TODO: フラッシュメッセージの定義とルートへのリダイレクト
+       flash[:info] = "Email sent with password reset instructions"
+       redirect_to root_url
      else
        flash.now[:danger] = "Email address not found"
        render 'new'
      end
    end

    def edit
    end
  end

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

スクリーンショットは以下です。

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

サーバーログには、以下のようなエラーメッセージが残されています。

Completed 500 Internal Server Error in 681ms (ActiveRecord: 73.3ms)

ArgumentError (wrong number of arguments (given 1, expected 0)):

app/mailers/user_mailer.rb:8:in `password_reset'
app/models/user.rb:61:in `send_password_reset_email'
app/controllers/password_resets_controller.rb:9:in `create'

「引数の数が0でなければならないのに、実際には1つの引数が渡されている」というエラーです。

そういえば、実装済みのテストでも、同じエラーメッセージが表示されていました。

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

ERROR["test_password_resets", PasswordResetsTest, 2.324982799999816]
 test_password_resets#PasswordResetsTest (2.33s)
ArgumentError:         ArgumentError: wrong number of arguments (given 1, expected 0)
            app/mailers/user_mailer.rb:8:in `password_reset'
            app/models/user.rb:61:in `send_password_reset_email'
            app/controllers/password_resets_controller.rb:9:in `create'
            test/integration/password_resets_test.rb:18:in `block in <class:PasswordResetsTest>'

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

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

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

以下のようなサーバーログが残されていたことを前提とします。

Started POST "/password_resets" ...略
Processing by PasswordResetsController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"/oPYr3/VP2xSwgvqxft9L9YtfbCUeGvTQbR5MFLr0Bpg/ihNJbwSZCyJwwgmrB0QRsZWxTA7Cx51Is7h5gHEJA==", "password_reset"=>"[FILTERED]", "commit"=>"Submit"}
  User Load (4.8ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "example-2@railstutorial.org"], ["LIMIT", 1]]
   (0.7ms)  begin transaction
  SQL (15.7ms)  UPDATE "users" SET "reset_digest" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["reset_digest", "$2a$10$Zln4PTKIyOO8/7TWgmY6nuploURJrovXjWxjiK4LeuAIPnwa.QXk6"], ["updated_at", "2019-12-13 20:39:33.489265"], ["id", 3]]
   (11.4ms)  commit transaction
   (0.1ms)  begin transaction
  SQL (13.4ms)  UPDATE "users" SET "updated_at" = ?, "reset_sent_at" = ? WHERE "users"."id" = ?  [["updated_at", "2019-12-13 20:39:33.528786"], ["reset_sent_at", "2019-12-13 20:39:33.526750"], ["id", 3]]
   (11.7ms)  commit transaction
# rails console --sandbox
>> user = User.find(3)
>> user.reset_digest
=> "$2a$10$Zln4PTKIyOO8/7TWgmY6nuploURJrovXjWxjiK4LeuAIPnwa.QXk6"
>> user.reset_sent_at
=> Fri, 13 Dec 2019 20:39:33 UTC +00:00

当該ユーザーのreset_digestreset_sent_atには、確かにサーバーログと同じ値が格納されています。

以下のテストが成功する状況であれば、RDBにreset_digestreset_sent_atは正しく保存されるはずです。

test "reset_digest should save with valid post request" do
  post password_resets_path, params: { password_reset: { email: @user.email} }
  assert_not_equal @user.reset_digest, @user.reload.reset_digest
end

送信メールのテスト

パスワード再設定用メイラーのテストも、アカウント有効化用メイラーのテストと同様のやり方で実装することができます。場所は同じくtest/mailers/user_mailer_test.rbです。

以下、テスト名「password reset」としてテストを追加していきます。

test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase
  test "account_activation" do
    user = users(:rhakurei)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name,              mail.body.encoded
    assert_match user.activation_token,  mail.body.encoded
    assert_match CGI.escape(user.email), mail.body.encoded
  end

  test "password reset" do
    user = users(:rhakurei)
    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 test test/mailers/user_mailer_test.rb
Running via Spring preloader in process 593
Started with run options --seed 63825

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

Finished in 1.16035s
2 tests, 16 assertions, 0 failures, 0 errors, 0 skips
# rails test
Running via Spring preloader in process 606
Started with run options --seed 64282

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

Finished in 7.27392s
48 tests, 213 assertions, 0 failures, 0 errors, 0 skips

テストは成功していますね。

演習 - 送信メールのテスト

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

上述rails test test/mailers/user_mailer_test.rbの結果の通り、現時点でのテスト結果はgreenですね。

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

test/mailers/user_mailer_test.rbの内容を以下のように変更するとどうなるか、という話ですね。

test/mailers/user_mailer_test.rb
  require 'test_helper'

  class UserMailerTest < ActionMailer::TestCase
    ...略

    test "password reset" do
      user = users(:rhakurei)
      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
+     assert_match user.email, mail.body.encoded
    end
  end

結果は以下のようになります。「メール本文中に、/rhakurei@example\.com/という正規表現で示される文字列が含まれていない」という理由でテストが失敗しています。

# rails test test/mailers/user_mailer_test.rb
Running via Spring preloader in process 619
Started with run options --seed 22084

 FAIL["test_password_reset", UserMailerTest, 1.297335600000224]
 test_password_reset#UserMailerTest (1.30s)
        Expected /rhakurei@example\.com/ to match ...略
----==_mimepart_5df563989470_26b2b14dbf885fc55451\r\nContent-Type: text/plain;
...略
http://example.com/password_resets/oUEWQLpWdB7Y7n4ErOw6rg/edit?email=rhakurei%40example.com
...略
----==_mimepart_5df563989470_26b2b14dbf885fc55451\r\nContent-Type: text/html;
...略
http://example.com/password_resets/oUEWQLpWdB7Y7n4ErOw6rg/edit?email=rhakurei%40example.com
...略
----==_mimepart_5df563989470_26b2b14dbf885fc55451--\r\n".
        test/mailers/user_mailer_test.rb:24:in `block in <class:UserMailerTest>'

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

Finished in 1.45204s
2 tests, 16 assertions, 1 failures, 0 errors, 0 skips

確かに「/rhakurei@example\.com/という正規表現で示される文字列が含まれていない」ようですね。

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