LoginSignup
9
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-01-28

近況報告

エンジニア転職成功しました。YouTubeもはじめました。

前回の続き

著者略歴
著者:YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目

第12章 パスワードの再設定 難易度 ★★★ 3時間

挫折しないRailsチュートリアルの進め方を先にお読みください↓↓

Railsチュートリアルで挫折しない3つのポイント

前章でアカウントの有効化の実装が完了し、ユーザーのメールアドレスが本人のものである確信が得られるようになった。

この章では、パスワードの再設定に取り組む。
この設定方法はアカウント有効化の方法と似通っていてる。

違う点としては、例えば

①パスワードの再設定する場合はビューを1つ変更する必要がある
②新しいフォームが新たに2つ(メールレイアウト用と新しいパスワードの送信用)必要になる。

コードを実際に書くまえに、パスワード再設定の想定手順をモックアップで確かめてみる。
まず、サンプルアプリケーションのログインフォームにforgot passwordリンクを追加する。

このforgot passwordリンクをクリックするとフォームが表示され、そこにメールアドレスを入力してメールを送信すると、メールが届き、そこにパスワード再設定用のリンクが記載されている。

この再設定用のリンクをクリックすると、ユーザーのパスワードを再設定してよいか確認を求めるフォームが表示される。

image.png

出典:図 12.1: 「forgot password」リンクのモックアップ

image.png

出典:図 12.2: 「forgot password」フォームのモックアップ

image.png

出典:図 12.3: パスワード再設定用フォームのモックアップ

パスワード再設定用のメイラーは既に生成されている筈なので、
本章では、ここで生成したメイラーにリソースとデータモデルを追加して、パスワードの再設定を実現していく。

アカウント有効化の際と似ていて、PasswordResetsリソースを生成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となる。

全体の流れは次の通り

①ユーザーがパスワードの再設定をリクエストすると、ユーザー宛に再設定用メールが届く。また、ユーザーが送信したメールアドレスをキーにして、DBからユーザー探し出す
②概要ユーザーがいた場合、再設定用のトークンとリセットダイジェストを生成する
③リセットダイジェストをDBに保存する。
④ユーザー宛のメールのURLに再設定用トークンを仕込み、ユーザーがクリックすると、メールアドレスをキーとしてユーザーを探し、DB内に保存しておいた再設定用ダイジェストと比較する。
⑤ユーザーが一致したら認証し、パスワード変更用のフォームをユーザーに表示する。

12.1 PasswordResetsリソース

セッションやアカウント有効化の時と同様に、まずはPasswordResetsリソースのモデリングから始める。

Userモデルに必要なデータを追加していく形で進めていく。

PasswordResetsもリソースとして扱っていきたいので、まずは標準的なRESTfulなURLを用意する。

有効化のときはeditアクションだけを取り扱ったが、今回はパスワードを再設定するフォームが必要なので、
ビューを描画するためのnewアクションとeditアクションが必要になる。

また、それぞれのアクションに対応する作成用/更新用のアクションも最終的なRESTfulなルーティングには必要となる。

上の変更を加える前に、いつものようにトピックブランチを作る。

$ git checkout -b password-reset
Switched to a new branch 'password-reset'

12.1.1 PasswordResetsコントローラ

準備が整ったところで、最初のステップとしてパスワード再設定用のコントローラを作ってみる。

先程のように今回のビューも扱うので、newアクションとeditアクションも一緒に生成している点に注意。

$ rails g controller PasswordResets new edit --no-test-framework

上記のコマンドでは、テストを生成していない

これはコントローラの単体テストを行うのではなく、11章で統合テストを作ったから。

また、今回の実装では新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、newcreateeditupdateのルーティングも用意する。

この変更は、前回と同様にルーティングファイルのresources行で行う。

routes.rb
Rails.application.routes.draw do
  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                                                              # usersリソースをRESTfullな構造にするためのコード。
  resources :account_activations, only: [:edit]                                 # editアクションのみaccount_activationsリソースを適用
  resources :password_resets,     only: [:new, :create, :edit, :update]         # password再設定用のリソースを適用
end

password_resetsリソースは、以下のRESTfulのルーティングに従っている。

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

出典:表 12.1: リスト 12.1のPasswordResetsリソースで提供されるRESTfulルーティング

new.html.erb
<% provide(:title, 'Log in') %>
<h1>ログイン</h1>

<div class="row"></div>
  <div class="col-md-6 col-md-offset-3">

    <!-- formの送信先を指定 -->

    <%= form_for :session, url: login_path do |f| %>                            <!-- 送信先をurlが/login、Sessionsコントローラのcreateアクションに@userを送り、fブロックに代入-->

    <!-- form作成-->

      <%= 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>ログイン状態を保持する</span>
      <% end %>

    <!-- 送信ボタン-->

      <%= f.submit "送信", class: "btn btn-primary" %>
    <% end %>

    <!-- 登録ページへのボタン -->

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

スクリーンショット 2019-01-27 17.39.16.png

演習

1:テストがパスすることを確認。

確認済み。

2:名前付きルートでは、_pathではなく_urlを使うがそれはなぜか?

トークンのURLは絶対パス(https://〜)を使用する必要があるため。

12.1.2 新しいパスワードの設定

パスワード再設定のデータモデルも、アカウント有効化の場合と似通っている。

記憶トークンや有効化トークンでの実装パターンに倣って、パスワードの再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意していく。

もしトークンをハッシュ化せずにDBに保存してしまうとすると、攻撃者によってDBからトークンを読み出された時、セキリュティ上の問題が生じる。

例えば攻撃者がユーザーのメールアドレスにパスワード再設定のリクエストを送信し、このメールと盗んだトークンを組み合わせて攻撃者がパスワード再設定リンクを開けば、アカウントを奪い取ることができてしまう。

したがって、パスワードの再設定では必ずダイジェストを使うようにする。

セキュリティ上の注意点はもう1つ、それは再設定用のリンクはなるべく短時間で期限切れになるようにしなければならない。

そのために、再設定メールの送信時刻も記録する必要がある。

以上の背景に基づいて、reset_digest属性とreset_sent_at属性をUserモデルに追加する。

image.png

出典:図 12.5: パスワード再設定用のカラムを追加したUserモデル

次を実行して、マイグレーションを反映する。

$ rails g migration add_reset_to_users reset_digest:string reset_sent_at:datetime
$ rails db:migrate

新しいパスワード再設定の画面を作成するために、新しいセッションを作成するためのログインフォームを使う。

新しいパスワード再設定フォームは、多くの共通点があるが、重要な違いとして、
from_forで扱うリソースとURLが異なっている点と、パスワード属性が省略されている点が挙げられる。

変更を反映した結果がこれ

new.html.erb
<%= provide(:title, "Log in" ) %>
<h1>パスワード再発行</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>

スクリーンショット 2019-01-27 18.51.55.png

演習

1:上記のコードでform_forメソッドでなぜ@password_resetではなく、:password_resetを使っているのか?

シンボルを使ってフォームをするとRailsが自動で送信先に値を割り当ててくれるから。

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

パスワード再設定のフォームから送信を行なった後、メールアドレスをキーとしてユーザーをDBから見つけ、
パスワード再設定用トークンと送信時のタイムスタンプでDBの属性を更新する必要がある。

それに続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する。
送信が無効の場合は、ログインと同様にnewビュー(同じページ)を出力してflash.nowメッセージを表示する。

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
      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モデル内で書く

user.rb
  # パスワード再設定の属性を設定する
  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

この時点では、無効なメールアドレスを入力した場合のみ正常に動作する。

正しいメールアドレスを送信した場合にもアプリケーションが正常に動作するためには、パスワード再設定のメイラーメソッドを定義する必要がある。

スクリーンショット 2019-01-27 19.46.18.png

出典:図 12.7: 「forgot password」フォームに無効なメールアドレスを入力した場合

演習

1:有効なメールアドレスをフォームから送信してみると、どんなエラーが表示されるか?

スクリーンショット 2019-01-27 22.25.07.png

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

$ rails c
$ user = User.find_by(email: "〇〇@gmail.com")
>> user.reset_digest
=> "$2a$10$xjW3SpB4fTdrQLn8ADGgAeqiFoEkYAJ60V2SPem5P1MvdF/sfIWF."
>> user.reset_sent_at
=> Sun, 27 Jan 2019 13:25:03 UTC +00:00

12.2 パスワード再設定のメール送信

PasswordResetsコントローラで、createアクションがほぼ動作するところまで持っていった。
残すはパスワード再設定に関するメールを送信する部分。

すでにUserメイラーを生成した時に、デフォルトのpassword_resetメソッドもまとめて生成されるはず。

12.2.1 パスワード再設定のメールとテンプレ

前章ではUserメイラーにあるコードをUserモデルに移すリファクタリングを行なったので、
同様のリファクタリング作業を、パスワード再設定に対しても行う。

UserMailer.password_reset(self).deliver_now

上記のコードの実装に日つゆなメソッドは、アカウント有効化用メイラーメソッドとほぼ同じ。

最初にUserメイラーにpassword_resetメソッドを作成し、続いてテキストメールのテンプレートとHTMLメールのテンプレートをそれぞれ定義する。

user_mailer.rb
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
password_reset.text.rb
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.
password_reset.html.erb
<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のメールプレビュー機能でパスワード再設定のメールをプレビューする。

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

スクリーンショット 2019-01-28 12.43.50.png

htmlバージョン

スクリーンショット 2019-01-28 12.44.41.png

textバージョン

これで、正しいメールアドレスを送信することが可能となる。

実際にメールを送信すると、サーバーログでは以下のように表示される。

スクリーンショット 2019-01-28 12.56.16.png

演習

1:ブラウザから送信メールのプレビューをしてみると、Dateの欄にはどんな情報が表示されるか?

送信した日時の情報が表示される

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

----==_mimepart_5c4e7ea1e63f_13282cec988248a0
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="https://eac437457e484fe491559aaa135f7f93.vfs.cloud9.us-east-2.amazonaws.com/password_resets/5yaWSzN-nDQmfgXWBukybw/edit?email=yuukitetsuyanet%40gmail.com">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>

3:コンソールに移り、先ほどの演習問題でパスワード再設定をしたUserオブジェクトを探してみる。
オブジェクトを見つけたら、そのオブジェクトが持つreset_digestreset_sent_atの値を確認してみる。

>> user.reset_digest
=> "$2a$10$ytvBnTzBj4tEkPhGNthyLeXRb0ohU.38BsPQr/5cabGIrpBg7JlI."
>> user.reset_sent_at
=> Mon, 28 Jan 2019 04:01:37 UTC +00:00

12.2.2 送信メールのテスト

アカウント有効化のテストと同様に、メイラーメソッドのテストを書いていこう。

user_mailer_test.rb
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

これでテストはパスする。

$ rails t

演習

1:メイラーのテストだけを実行してパスするか確認。

確認済み。

2:GCI.escapeを削除したらテストが失敗することを確認。

確認済み。

12.3 パスワードを再設定する

無事に送信メールを生成できたので、次はPasswordResetsコントローラのeditアクションの実装を進めていく。
また、前章のときと同様に、統合テストを使ってうまく動作しているかのテストを行っていく。

12.3.1 editアクションで再設定

前章で見せたパスワード再設定の送信メールには、次のようないリンクが含まれている。

https://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=fu%40bar.com

このリンクを機能させるには、パスワード再設定フォームを表示するビューが必要。

このビューはユーザーの編集フォームと似ているが、今回はパスワード入力フィールドと確認用フィールドだけで十分。

この作業を行う上で、面倒な点がある。
メールアドレスをキーとしてユーザーを検索するためには、
editアクションとupdateアクションの両方でメールアドレスが必要になる。

例のメールアドレス入りリンクのおかげで、editアクションでメールアドレスを取り出すことは問題ない。
しかし、フォームを一度送信してしまうと、この情報は消えてしまう。

この値はどこに保持しておくのがよいのか。

今回はこのメールアドレスを保持するため、隠しフィールドとしてページ内に保存する手法をとる。
これにより、フォームから送信した時に、他の情報と一緒にメールアドレスが送信されるようになる。

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_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>

上記のコードではフォームタグヘルパーを使っている点に注意。

hidden_field_tag :email, @user.email

この書き方だと、再設定用リンクをクリックした際に、params[:email]に保存される。

以前は、

f.hidden_field :email, @user.email

このような書き方をしていたが、これだとparams[:user][:email]に保存されてしまう。

今度は、このフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義していく。

アカウント有効化の場合と同様、params[:email]のメールアドレスに対応するユーザーをこの変数に保存する。

続いて、params[:id]の再設定用トークンと、authenticated?メソッドを使って、このユーザーが正当なユーザーであることを確認する。

editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行う。

password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,      only: [:edit, :update]
  before_action :valid_user,    only: [:edit, :update]

  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

  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

上記のコードでは

authenticated?(:reset, params[:id])

このようなコードを使っている。

authenticated?(:remember, cookies[:remember_token])

以前使ったこのコードと似ている。

要は、:rememberの部分を:resetにして、受け取ったユーザーidの値をトークン化してダイジェスト化したものが、:resetの値をダイジェスト化したものと一致していれば、trueを返すという仕組み。

さらに

authenticated?(:activation, params[:id])

これはアカウント有効化のコントローラで使ったメソッドである。

つまり、以上のコードが11.1で使った認証メソッドであり、今回追加したコードで全て実装が完了したことになる。

これで、送られてきたメールのURLをクリックすると、パスワード再設定のフォームが出力されるようになった。

スクリーンショット 2019-01-28 16.21.06.png

演習

1:Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてみる。
そのリンクをブラウザから表示してみて、Reset Passwordの画面が表示されるか確認。

確認済み

2:実際に新しいパスワードを送信するとどうなるか?

スクリーンショット 2019-01-28 16.22.50.png

12.3.2 パスワードを更新する

AccountActivationsコントローラのeditアクションでは、ユーザーの有効化ステータスをfalseからtrueに変更下が、今回の場合はフォームから新しいパスワードを送信するようになっている。

したがって、フォームからの送信に対応するupdateアクションが必要になる。

このupdateアクションでは

①パスワード再設定の有効期限が切れていないか
②無効なパスワードであれば失敗させる(失敗した理由も表示する)
③新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった)
④新しいパスワードが正しければ、更新する

このような順序で確認し、処理を行う。

まず、1についてはeditとupdateアクションに次のようなメソッドとbeforeフィルターを用意することで対応できる。

before_action :check_expiration, only: [:edit, :update]

check_expirationは有効期限をチェックするPrivateメソッドと定義する。

def check_expiration
  if @user.password_reset_expired?
    flash[:danger] = "Password reset has expired."
    redirect_to new_password_reset_url
  end
end

password_reset_expired?は期限切れかどうかを確認するインスタンスメソッドで、のちに定義する。

これから②の問題を解消していく。

②では更新が失敗した時にeditビューが再描画され、edit.html.erbのパーシャルにエラーメッセージを表示させるようにすれば解決できる。

次に④については、更新が成功した時にパスワードを再設定し、あとはログインに成功した時と同様の処理を進めていけば問題ない。

③についての処理が難しい(パスワードがから文字だった場合の処理)

その理由として、以前Userモデルを作っていた時に、パスワードが空でも良いという実装をしたから。
したがって、このケースについては明示的にキャッチするコードを追加する必要がある。

そのために、@userオブジェクトにエラーメッセージを追加する。

@user.errors.add(:password, :blank)

このように書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれるようになる。

以上の結果をまとめて、①のpassword_reset_expired?の実装を除いた、
全てのケースに対応したupdateアクションがこれ

passowrd_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]

  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?
      @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

  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

あとはこれを実装するだけ。

今回は始めからリファクタリングしていた(Userモデルに移譲する前提)ので、次のようにコードを書いていた。

@user.password_reset_expired?

上記のコードを動作させるために、password_reset_expired?メソッドをUserモデルで定義する。

このメソッドでは、パスワード再設定の期限を設定して、2時間以上パスワードが再設定されなかった場合は期限切れとする処理を行う。

これをRubyで表現すると

reset_sent_at < 2.hours.ago

上の<記号を「〜より少ない」と読んでしまうと、パスワード再設定メール送信時から経過した時間が、2時間より少ない場合となってしまい、困惑してしまう点に注意。

ここで行っている処理は、「少ない」ではなく「早い」と捉えると理解しやすい。

つまり、<記号を〜より早い時刻と読む。

こうすることで、パスワード再設定メールの送信時刻が、現在時刻より2時間以上前の場合となり、期待通りの条件となる。

実際のメソッドの処理を書いていく。

user.rb
class User < ApplicationRecord


  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    rent_sent_at < 2.hours.ago
  end

  private

end

リストコードを使うと、updateアクションが動作するようになる。

送信が無効だった場合と有効だった場合の画面がこちら。

スクリーンショット 2019-01-28 19.01.27.png

スクリーンショット 2019-01-28 19.02.09.png

演習

1:Railsサーバーのログから再設定リンクを取得し、confirmationの文字列をわざと間違えて送信するとどんなエラーメッセージが出るか?

確認済み

2:コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つける。
そのオブジェクトのpassword_digestの値を、送信失敗時と送信成功時で見比べてみる。

取得した値が異なっていることを確認する。

$ user = User.find_by(email: "yuukitetsuyanet@gmail.com")
$ user.password_digest
=> "$2a$10$aKbLCrMfGEWYXAQ5NSEvxuU5qI/sOFyiehIgFwNMWMifDcRX7bxFO"
$ user.reload
$ user.password_digest
=> "$2a$10$RtF8nSd22GLENhTmlBTkPeaqyPEs9E8v7f3UeR6UecG4ydjxCuNOO"

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

この項では、送信に成功した場合と失敗した場合の統合テストを作成する。

まずはパスワード再設定のテストファイルを生成する。

$ rails g integration_test password_resets

パスワード再設定をテストする手順は、アカウント有効化のテストと多くの共通点があるが、テストの冒頭部分には次のような違いがある。

最初にforgot passwordフォームを表示して無効なメールアドレスを送信し、
次はそのフォームで有効なメールアドレスを送信する。

後者ではパスワード再設定用トークンが作成され、再設定用メールが送信される。

続いて、メールのリンクを開いて無効な情報を送信し、次にそのリンクから有効な情報を送信して、それぞれが期待通りに動作することを確認する。

password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

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

  test "should do something" 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

ここで、inputタグを使っている点に注目。

assert_select "input[name=email][type=hidden][value=?]", user.email

上記のコードは、inputタグに

  • 正しい名前
  • type="hidden"
  • メールアドレス

があるかどうかを確認している。

<input id="email" name="email" type="hidden" value="michael@example.com" />

上記のコードを使うとテストはパスするはず。

$ rails t

演習

1:user.rbでのcreate_reset_digestメソッドはupdate_attributeを2回呼び出しているが、これは各行で一回ずつDBをへ問い合わせしていることになる。
以前と同様、update_columns呼び出しにまとめて見る。

また、変更後にテストがパスすることを確認する。

user.rb
  # パスワード再設定の属性を設定する
  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
11 tests, 15 assertions, 0 failures, 0 errors, 0 skips

2:期限切れのパスワード再設定で発生する分岐を統合テストで網羅してみる。

password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

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

  test "should do something" 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

  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時間以内であれば履歴からパスワード再設定フォームを表示させてパスワードを更新することが可能。

この問題を解決するため、パスワードの再設定に成功したらダイジェストをnilになるように変更する。

password_resets_controller.rb
  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:password_resets_test.rbに3でやった部分のテストを書いてみる。

password_resets_test.rb
    # 有効なパスワードとパスワード確認
    patch password_reset_path(user.reset_token),
          params: { email: user.email,
                    user: { password:                 "foobaz",
                            password_confirmation:    "foobaz" } }
    assert_nil user.reload.reset_digest
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user

12.4 本番環境でのメール送信

これでパスワード再設定の実装も終わったので、あとは前章と同様に、development環境だけでなくproduction環境でも動くようにするだけ。

セットアップの手順はアカウント有効化と全く同じ。

SendGridを使ってメールを送信する。

設定は11章で行なっているので、あとはGitのトピックブランチをmasterにマージして、Herokuにデプロイする。

$ rails t
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset
$ rails t
$ git push
$ git push heroku
$ heroku run rails db:migrate

実際にproduction環境でパスワード再設定のメールを送り、再設定してみる。

SendGridがスパム判定で使えないのでHeroku logsから

2019-01-28T16:19:38.626415+00:00 app[web.1]: <!DOCTYPE html>
2019-01-28T16:19:38.626417+00:00 app[web.1]: <html>
2019-01-28T16:19:38.626419+00:00 app[web.1]: <head>
2019-01-28T16:19:38.626420+00:00 app[web.1]: <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
2019-01-28T16:19:38.626422+00:00 app[web.1]: <style>
2019-01-28T16:19:38.626427+00:00 app[web.1]: /* Email styles need to be inline */
2019-01-28T16:19:38.626429+00:00 app[web.1]: </style>
2019-01-28T16:19:38.626431+00:00 app[web.1]: </head>
2019-01-28T16:19:38.626432+00:00 app[web.1]: 
2019-01-28T16:19:38.626434+00:00 app[web.1]: <body>
2019-01-28T16:19:38.626435+00:00 app[web.1]: <h1>Password reset</h1>
2019-01-28T16:19:38.626437+00:00 app[web.1]: 
2019-01-28T16:19:38.626438+00:00 app[web.1]: <p>To reset your password click the link below:</p>
2019-01-28T16:19:38.626440+00:00 app[web.1]: 
2019-01-28T16:19:38.626442+00:00 app[web.1]: <a href="https://yuuki-heroku-sample.herokuapp.com/password_resets/9ZcCCHWb8TTuRDjw3Nu-JQ/edit?email=yuukitetsuyanet%40gmail.com">Reset password</a>
2019-01-28T16:19:38.626444+00:00 app[web.1]: 
2019-01-28T16:19:38.626445+00:00 app[web.1]: <p>This link will expire in two hours.</p>
2019-01-28T16:19:38.626447+00:00 app[web.1]: 
2019-01-28T16:19:38.626449+00:00 app[web.1]: <p>
2019-01-28T16:19:38.626450+00:00 app[web.1]: If you did not request your password to be reset, please ignore this email and
2019-01-28T16:19:38.626452+00:00 app[web.1]: your password will stay as it is.
2019-01-28T16:19:38.626453+00:00 app[web.1]: </p>
2019-01-28T16:19:38.626455+00:00 app[web.1]: </body>
2019-01-28T16:19:38.626456+00:00 app[web.1]: </html>
2019-01-28T16:19:38.626458+00:00 app[web.1]: 

スクリーンショット 2019-01-29 1.21.27.png

スクリーンショット 2019-01-29 1.21.44.png

演習

1:ユーザー登録を試して、登録したメールアドレス宛にメールは届くか確認。

確認済み。

2:メールを受信できたら、実際にメールをクリックしてアカウント有効化して見る。
また、有効化のログを調べてみる。

2019-01-28T16:25:28.530104+00:00 app[web.1]: D, [2019-01-28T16:25:28.529979 #8] DEBUG -- : [a0a47171-d01a-4643-b59c-de3ce17467b5]   SQL (3.5ms)  UPDATE "users" SET "activated" = 't', "activated_at" = '2019-01-28 16:25:28.520822' WHERE "users"."id" = $1  [["id", 4]]

SQLで有効化されているのがわかる。

3:パスワードの再設定を試してパスワードの再設定が正しくできるか?

確認済み。

第13章へ

単語集

  • mail.from

送信者をメールアドレスでなく、名前で表示する時に使う

mail(from: '"表示名" <noreply@yoursite.com>' )
  • response.body

全てのHTML本文を返すメソッド。

9
6
1

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
9
6