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
コマンドにおけるアクションの自動生成」を行います。対象はnew
とedit
の両アクションです。
テストを生成していない
rails generate controller
コマンドにおいて、--no-test-framework
というオプションは、「テストを生成しない」という意味のオプションです。テストを生成しない理由は、今回実装するPasswordResetsコントローラーにおける、「コントローラーの単体テストは実装せず、統合テストのみでカバーする」という方針に基づくものです。
パスワード再設定用リソースに関するルーティングの定義
「ビューを必要とする」ということは、「リソースにアクセスするためのURLが必要となる」ということでもあります。そうしたURLを定義するのは、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リソースで必要となるルーティングは、new
とedit
、およびそれぞれに対してRDBに変更を反映するcreate
とupdate
、以上の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
を使うのでしたね。
ログイン画面のビューの実装変更
まずはじめに、ログイン画面のビューにパスワード再設定用のリンクを追加します。
<% 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」と表示されたリンクが追加されていますね。

但し、現時点で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モデルの内容を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
となっています。
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
の内容です。
<% 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
となる
-
- パスワードの入力が省略されている
<% 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
アクションで定義していくことになります。

演習
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
変数の内容を定義する
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
メソッドの実装」まで完了した段階で一旦中断し、以下のコードを実装します。
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)。どんなエラーメッセージが表示されたでしょうか?
スクリーンショットは以下です。

サーバーログには、以下のようなエラーメッセージが残されています。
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_digest
とreset_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_digest
とreset_sent_at
には、確かにサーバーログと同じ値が格納されています。
以下のテストが成功する状況であれば、RDBにreset_digest
とreset_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」としてテストを追加していきます。
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
の内容を以下のように変更するとどうなるか、という話ですね。
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/
という正規表現で示される文字列が含まれていない」ようですね。