LoginSignup
1
1

More than 1 year has passed since last update.

Railsチュートリアル(第6版) 第12章 パスワードの再設定

Posted at

第12章

第11章ではアカウント有効化の実装が完了した。

今回の章ではパスワードを忘れた時に再設定できる機能を追加する。
やることは第11章と似ている。

流れ

1.ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
2.該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応する再設定ダイジェストを生成する
3.再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
4.ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する(トークンを認証する)
5.認証に成功したら、パスワード変更用のフォームをユーザーに表示する

PasswordResetsリソース

まずは、PasswordResetsリソースのモデリングから始める。
前章と同じく新たなモデルは作らず、代わりに必要なデータをUserモデルに追加する。

PasswordResetsをリソースとして扱うため、RESTfulなURLを用意する。
今回は、ビューを描画するためのnewアクションとeditアクションが必要になる。

$ git checkout -b password-reset

トピックブランチを作成する。

PasswordResetsコントローラ

newアクションとeditアクションを含んだコントーラーの作成

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

テストを生成しないオプションを指定している。
理由として、統合テストでカバーしていくから。

新しいパスワードを再設定するためのフォームとUserモデル内のパスワードを変更するためのフォームが必要。
従って、newcreateeditupdateのルーティングを用意する。

config/routes.rb
Rails.application.routes.draw do
 resources :password_resets,     only: [:new, :create, :edit, :update]
end
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)

RESTfulルーティング

パスワード再設定画面へのリンクを追加

<%= link_to "(forgot password)", new_password_reset_path %>

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

image.png

新しいパスワードの設定

今までのように、トークン用の仮想的な属性とそれに対応するダイジェストを用意する。
トークン関連で学んだ事だが、トークンをハッシュ化せず(平文で)データベースに保存してしまうと、攻撃者がトークンを盗み出したときセキュリティ上問題がある。なので、ダイジェストを使おう。
また、再設定用リンクは期限を設ける。そのために、送信時刻を記録する必要がある。
以上を踏まえて、reset_digest属性とreset_sent_at属性をUserモデルに追加する。

image.png
参照:railsチュートリアル

上記のUsersテーブルのするためマイグレーションを実行

$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime

いつものようにマイグレーション
$ rails db:migrate

新しいパスワード再設定画面ビュー

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>

image.png

演習
解答例
シンボルを使ってフォームを送信する事で、Railsが自動的に送信先に値を割り当てる。

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

Forgot passwordから送信後、メールアドレスをキーとしてユーザーをデータベースから探し、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する。
続いて、ルートURLにリダイレクトし、フラッシュメッセージを表示する。なお、送信が無効な場合は、newページを出力してfash.nowメッセージを表示する。

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
      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モデルにもコードを追加していく。(create_activation_digestメソッドと似ている)

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

image.png
この時点では、無効なメールアドレスを入力すると正常に動作する。
そして、正しいメールアドレスを送信した場合にも正常に動作させるには、パスワード再設定のメイラーメソッドを定義する必要がある。

演習
エラーメッセージの確認
image.png

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

パスワード再設定に関するメールを送信する部分を作成する。

第11章でUserメイラーを生成した時、デフォルトのpassword_restメソッドもまとめて生成されている。

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

UserメイラーにあるコードをUserモデルに移すリファクタリングを行う。

UserMailer.password_reset(self).deliver_now

このコードの実装のために必要なpassword_resetメソッドを、Userメイラーに設定する。

app/mailers/user_mailer.rb
  def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end

パスワード再設定テンプレート(テキスト)

app/views/user_mailer/password_reset.text.erb
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
<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
class UserMailerPreview < ActionMailer::Preview

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

end

メールのプレビュー
image.png

送信メールのテスト

メイラーメソッドのテストを書く。

test/mailers/user_mailer_test.rb
  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

パスワードを再設定する

PasswordResetsコントローラのeditアクションの実装を進める。
また、統合テストでも行う。

editアクションで再設定

パスワード再設定の送信メールには、以下のようなリンクがある。

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

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

今回ちょっと面倒なのが、メールアドレスをキーとしてユーザーを検索するのに、editアクションとupdateアクションの両方でメールアドレスが必要になる。
なので、最初はメールアドレス入りリンクのおかげでeditアクションはOK。
しかし、フォームを一度送信すると、この情報は消える。
故に、この値をどこかに保存しよう!となる。
そこで今回使うのは、隠しフィールド。これでページ内にメールアドレスを保持し、フォームから送信した時に他の情報と一緒にメールアドレスが送信されるようになる。

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>

上のタグでは、フォームタグヘルパーを使っている。
hidden_field_tag :email, @user.email

今までは以下のようなコードを書いていた
f.hidden_field :email, @user.email

・ポイント
前者のhidden_field_tagではメールアドレスがparams[:email]に保存される。
後者はparams[:user][:email]に保存される。

今後、上記のフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義する。
params[:email]のメールアドレスに対応するユーザーを先ほどの変数に入れる。
そして、params[:id]の再設定用トークンとauthenticated?メソッドで、このユーザーが正当なユーザーかことを確認する。
editアクションとupdateアクションのどちらでも正当な@userが存在しなければならないため、beforeフィルタで@userの検索とバリデーションを行う。

app/controllers/password_resets_controller.rb
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

メールアドレスを入力し送信した結果にあったリンクから開くと、、、
image.png
パスワード再設定用のページが出てきた。

演習
image.png
現時点では、パスワードを更新するボタンを押しても動作しない。

パスワードを更新する

フォームからの送信に対応するupdateアクションが必要だ。

updateアクションの4つのケース
1.パスワード再設定の有効期限が切れていないか
2.無効なパスワードであれば失敗させる(失敗した理由も表示する)
3.新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった)
4.新しいパスワードが正しければ、更新する

1、2、4はこれまでの知識で対応可能。

1は、editupdateアクションに以下のメソッドとbeforeフィルターを用意し対応する。

before_action :check_expiration, only: [:edit, :update]    # (1)への対応案

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

上記のメソッドを呼び出しbeforeフィルターで保護したので、2と4のケースには対応できそう。
2は、無効なパスワードであれば失敗はOK
4は、更新が成功したらパスワードを再設定後、ログインさせればOK

問題としてパスワードが空文字だった時のケース。
以前Userモデルを作っていた時に、パスワードが空でも良いという実装をした。
従って、今回は明示的にキャッチする必要がある。
今回は、@userオブジェクトにエラーメッセージを追加するという方法を取る。
やり方は、errors.addを使ってエラーメッセージを追加する。

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

以上の結果から、1を除いたすべてのケースに対応したupdateアクションが完成する。

app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
 before_action :check_expiration, only: [:edit, :update]    # (1)への対応

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

    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end

@user.password_reset_expired?を動作させるため、password_reset_expired?メソッドをUserモデルに定義する。
2時間以上パスワードが再設定されなかった場合には、期限切れとする。

reset_sent_at < 2.hours.ago

不等号の「<」は、「より少ない」と読むのではなく「早い」と考えると良い。
今回は2時間より早い=2時間以内捉えられる。

password_reset_expired?メソッドの実装。

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

これで全体の実装はOK

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

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

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

$ rails generate integration_test password_resets

パスワード再設定手順
・「forgot password」フォームを表示して無効なメールアドレスを送信
・次にそのフォームで有効なメールアドレスを送信
・後者ではパスワード再設定用のトークンが生成され、再設定用のメールが送信される。
・続いて、メールのリンクを開き、無効な情報を送信
・次にそのリンクから有効な情報を送信
・それぞれが期待通りに動作しているかの確認を行う。

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

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:              "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タグに正しい名前、tyepe="hidden"、メールアドレスがあるか確認している。

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

テストはOK

本番環境でのメール送信

本番環境でのメール送信は第11章でやったので、あとはGitのトピックブランチをmasterにマージしておく。

$ rails test
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset

リモートレポジトリにプッシュし、Herokuにデプロイ

$ rails test
$ git push && git push heroku
$ heroku run rails db:migrate

最後に

第11章と似ている個所もあったが、難しい章になったと思う。

残りの章では、マイクロポスト機能とフォローなどのステータスフィード機能を実装していく。

has_manyhas_many:throughが出てくる。

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