# 12章
## パスワードの再設定
パスワードを忘れたときのパスワードの再設定に取り組む。
ログインフォームに「forgot password」リンクを追加し、そこにメールアドレスを入力してメールを送信すると、そのメールにパスワードの再設定用のリンクが記載される。その再設定用のリンクをクリックすると、ユーザのパスワードを再設定してよいか確認を求めるフォームが表示される。
1.ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
2.該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する
3.再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
4.ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)
5.認証に成功したら、パスワード変更用のフォームをユーザーに表示する
## PasswordResetsリソース
パスワードを再設定するフォームが必要。ビューを描画するためのnewアクションとeditアクションが必要になる。
## PasswordResetsコントローラ
パスワード再設定用のコントローラを作る。newアクションとeditアクションを同時に作成する。
```bash
rails generate controller PasswordResets new edit --no-test-framework
```
上のコマンドではテストを生成しないというオプションを指定している。今回はコントローラの単体テストではなく、統合テストでカバーしていく。
今回の実装では、パスワードを再設定するためのフォームとUserモデル内のパスワードを変更するためのフォームが必要なのでnew, create, edit, updateのルーティングを用意する。
**config/routes.rb**
```ruby
resources :password_resets, only: [:new, :create, :edit, :update]
```
|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)|
パスワード再設定画面へのリンクを追加する
**app/views/sessions/new.html.erb**
```html
<% 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>
```
## 演習
1.この時点で、テストスイートが greenになっていることを確認してみましょう。
A.
GREENになった。
2.表 12.1の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。ヒント: アカウント有効化で行った演習 (11.1.1.1) と同じ理由です。
A.
メールのURLからアクセスするため。
## 新しいパスワードの設定
パスワード再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意する。また、再設定メールの送信時刻も記録する必要がある。以上の背景に基づいて、reset_digest属性とreset_sent_at属性をUsermoderu に追加する。
パうワード再設定用のカラムを追加したUserモデル

次を実行してマイグレーションに属性を追加する。
```bash
rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
```
そしてマイグレーション
```bash
rails db:migrate
```
再設定フォームを以下のように作成する。
```html
<% provide(:title, "Forgot password") %>
<h1>Forgot password</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>
```
## 演習
1.リスト 12.4のform_forメソッドでは、なぜ@password_resetではなく:password_resetを使っているのでしょうか? 考えてみてください。
A.
シンボルを使うことで、よりシンプルかつ自動的に値を割り当ててくれるらしい。
## createアクションでパスワード再設定
フォームから送信を行った後、メールアドレスをキーとしてユーザをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する。それに続いて、ルートURLにリダイレクトし、フラッシュメッセージをユーザに表示する。送信が無効の場合、newページを出力して、flash.nowメッセージを表示。
パスワード再設定用のcreateアクション
**app/controllers/password_resets_controller.rb**
```ruby
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
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
```
次にUserモデルにパスワード再設定用のメソッドを追加する。
**app/models/user.rb**
```ruby
class User < ApplicationRecord
# *************************************************
attr_accessor :remember_token, :activation_token, :reset_token
# *************************************************
before_save :downcase_email
before_create :create_activation_digest
.
.
.
# アカウントを有効にする
def activate
update_attribute(:activated, true)
update_attribute(:activated_at, Time.zone.now)
end
# 有効化用のメールを送信する
def send_activation_email
UserMailer.account_activation(self).deliver_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)
end
# *************************************************
# パスワード再設定のメールを送信する
# *************************************************
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
end
# *************************************************
private
# メールアドレスをすべて小文字にする
def downcase_email
self.email = email.downcase
end
# 有効化トークンとダイジェストを作成および代入する
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
end
end
```
## 演習
1.試しに有効なメールアドレスをフォームから送信してみましょう (図 12.6)。どんなエラーメッセージが表示されたでしょうか?
A.
ArgumentError in PasswordResetsController#create
2.コンソールに移り、先ほどの演習課題で送信した結果、(エラーと表示されてはいるものの) 該当するuserオブジェクトにはreset_digestとreset_sent_atがあることを確認してみましょう。また、それぞれの値はどのようになっていますか?
A.
```bash
irb(main):002:0> user.reset_digest
=> "$2a$10$7iG03I9Fv8ztvar5tWk0fuqyPg54CpMp9s6U8xDLIZXYjZDPBGUt."
irb(main):003:0> user.reset_sent_at
=> Wed, 01 Apr 2020 11:35:52 UTC +00:00
```
## パスワード再設定のメール送信
パスワード再設定に関するメールを送信する部分を作成していく。
## パスワード再設定のメールとテンプレート
最初にUserメイラーにpassword_resetメソッドを作成し、続いて、テキストメールのテンプレートとHTMLメールのテンプレートのそれぞれ定義する。
パスワード再設定のリンクをメール送信する
**app/mailers/user_mailer.rb**
```ruby
class UserMailer < ApplicationMailer
def account_activation(user)
@user = user
mail to: user.email, subject: "Account activation"
end
# *****************************************************
def password_reset(user)
@user = user
mail to: user.email, subject: "Password reset"
end
# *****************************************************
end
```
パスワード再設定のテンプレート(テキスト)
**app/views/user_mailer/password_reset.text.erb**
```bash
To reset your password click the link below:
<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>
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.
```
パスワード再設定のテンプレート(HTML)
**app/views/user_mailer/password_reset.html.erb**
```bash
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<%= link_to "Reset password", edit_password_reset_url(@user.reset_token,
email: @user.email) %>
<p>This link will expire in two hours.</p>
<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
```
次に、Railsのメールプレビュー機能でパスワード再設定のメールをプレビューする。
パスワード再設定のプレビューメソッド (完成)
**test/mailers/previews/user_mailer_preview.rb**
```ruby
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/account_activation
def account_activation
user = User.first
user.activation_token = User.new_token
UserMailer.account_activation(user)
end
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/password_reset
# *************************************************
def password_reset
user = User.first
user.reset_token = User.new_token
UserMailer.password_reset(user)
end
# *************************************************
end
```
## 演習
1.ブラウザから、送信メールのプレビューをしてみましょう。「Date」の欄にはどんな情報が表示されているでしょうか?
A.
Date:
Wed, 01 Apr 2020 12:07:07 +0000
送信した日時の情報が表示される
2.パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。
A.
```bash
----==_mimepart_5e84848bd4722_6f7c33447a482150
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<a href="http://localhost:3000/password_resets/VojzmwlxB4OH1A-3xAhK4Q/edit?email=example%40railstutorial.org">Reset password</a>
<p>This link will expire in two hours.</p>
<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
</body>
</html>
----==_mimepart_5e84848bd4722_6f7c33447a482150--
```
3.コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認してみましょう。
A.
```bash
irb(main):002:0> user.reset_digest
=> "$2a$10$LDOWGbJsSJzDoP71feklIOjUwV/.YkkkDNCpoqN5ms1xxISZDunD2"
irb(main):003:0> user.reset_sent_at
=> Wed, 01 Apr 2020 12:09:47 UTC +00:00
```
## 送信メールのテスト
メイラーメソッドのテストを書いていく。
パスワード再設定用メイラーメソッドのテストを追加する
**test/mailers/user_mailer_test.rb**
```ruby
require 'test_helper'
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
user = users(:michael)
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(: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
```
これでテストスイートはGREENになるはず
## 演習
1.メイラーのテストだけを実行してみてください。このテストは greenになっているでしょうか?
A.
```bash
rails test:mailers
```
GREEN
2.リスト 12.12にある2つ目のCGI.escapeを削除すると、テストが redになることを確認してみましょう。
A.
REDになった。
## パスワードを再設定する
PasswordResetsコントローラのeditアクションの実装と統合テストを作成していく。
## editアクションで再設定
パスワード再設定の送信メールには、次のようなリンクが含まれている
```bash
https://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=fu%40bar.com
```
このリンクを機能させる。
メールアドレスをキーとしてユーザを検索するためには、editアクションとupdateアクションの両方でメールアドレスが必要になり、この値をどこかに保持しておく必要がある。
今回は隠しフィールドとしてページ内に保存する手法をとる。
パスワード再設定のフォーム
**app/views/password_resets/edit.html.erb**
```ruby
<% 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>
```
フォームタグヘルパ―を使っている。
```bash
hidden_field_tag :email, @user.email
```
今まで
```bash
f.hidden_field :email, @user.email
```
前者ではメールアドレスがparams[:email]に保存されるが、後者ではparams[:user][:email] に保存される。
次に、このフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義する。params[:email]のメールアドレスに対応するユーザをこの変数に保存する。その後、params[:id]の再設定用トークンとauthenticated?メソッドを使って、ユーザを確認。また、いくつかのbeforeフィルタを使って、@userの検索とバリデーションを行う。
パスワード再設定のeditアクション
**app/controllers/password_resets_controller.rb**
```ruby
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
```
これでリンクを開いたときに、パスワード再設定のフォームが出力されるようになる。
## 演習
1.12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて、図 12.11のように表示されるか確かめてみましょう。
A.
表示される。
2.先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?
A.
Unknown action
The action 'update' could not be found for PasswordResetsController
## パスワードを更新する
フォームから送信に対応するupdateアクションが必要になる。このupdateアクションでは、次の4つのケースを考慮する必要がある。
1.パスワード再設定の有効期限が切れていないか
2.無効なパスワードであれば失敗させる (失敗した理由も表示する)
3.新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4.新しいパスワードが正しければ、更新する
これらのケースに対応したupdateアクションは以下のようになる。
**app/controllers/password_resets_controller.rb**
```ruby
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
before_action :check_expiration, only: [:edit, :update] # (1) への対応
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
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
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
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
# beforeフィルタ
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
# トークンが期限切れかどうか確認する
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
end
```
最後に、パスワード再設定の期限を設定して、2時間以上のパスワードが再設定されなかった場合期限切れとする処理を行う。
```ruby
reset_sent_at < 2.hours.ago
```
このように書くことで、「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前(早い)の場合」となり、 期待どおりの条件となる。
Userモデルにパスワード再設定用メソッドを追加する
**app/models/user.rb**
```ruby
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
```
## 演習
1.12.2.1.1で得られたリンク (Railsサーバーのログから取得) をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?
A.
The form contains 1 error.
Password confirmation doesn't match Password
2.コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう (図 12.13)。パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。ヒント: 新しい値はuser.reloadを通して取得する必要があります。
A
確認できた。.
## パスワードの再設定をテストする
送信に成功した場合と失敗した場合の統合テストを作成する。
まずは、パスワード再設定のテストファイルを生成する。
```bash
rails generate integration_test password_resets
```
作成した統合テストは以下のようになる。
**test/integration/password_resets_test.rb**
```ruby
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'
# メールアドレスが無効
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: "foobaz",
password_confirmation: "foobaz" } }
assert is_logged_in?
assert_not flash.empty?
assert_redirected_to user
end
end
```
このテストはGREENになる。
## 演習
1.リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 12.20に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。また、変更後にテストを実行し、 greenになることも確認してください。ちなみにリスト 12.20にあるコードには、前章の演習 (リスト 11.39) の解答も含まれています。
A.
```ruby
# 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)
```
2.リスト 12.21のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐 (リスト 12.16) を統合テストで網羅してみましょう (12.21 のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです)。期限切れをテストする方法はいくつかありますが、リスト 12.21でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます (なお、大文字と小文字は区別されません)。
A.
```ruby
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
```
3.2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。しかし、もっと良くする方法はまだあります。例えば、公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを表示させ、パスワードを更新してしまうことができてしまいます (しかもそのままログイン機構まで突破されてしまいます!)。この問題を解決するために、リスト 12.22のコードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう。
A.
コードを追加する。
4.リスト 12.18に1行追加し、1つ前の演習課題に対するテストを書いてみましょう。ヒント: リスト 9.25のassert_nilメソッドとリスト 11.33のuser.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。
A.
test "password resets" doの最後に
assert_nil user.reload['reset_digest'] を追加する。
## 本番環境でのメール送信(再掲)
再掲なので省略する。
最後に、デプロイなど
```bash
$ rails test
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset
$ rails test
$ git push
$ git push heroku
$ heroku run rails db:migrate
```