Help us understand the problem. What is going on with this article?

Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #13 パスワード再設定編

こんな人におすすめ

  • プログラミング初心者でポートフォリオの作り方が分からない
  • Rails Tutorialをやってみたが理解することが難しい

前回:#12 ActionMailer, アクティベーション編
次回:#14 ユーザ投稿表示, ページネーション編

この記事は、動画を観た時間を記録するアプリのポートフォリオです。
今回はメールでのパスワードを再設定をできるようにします。

今回の流れ

  1. パスワード再設定のイメージをつかむ
  2. ビューを作る
  3. トークンとダイジェストを生成しURL入りメールを送信する
  4. URLの情報が正しいか確認し再設定する

パスワード再設定のイメージ

パスワード再設定は#12で紹介したアクティベーションと似ています。
ぜひ比較しながらご覧ください。
lantern_lantern_reset_password_sitemap.png
アクティベーションの時はビューが必要ありませんでした。
しかし今回は2つのビュー4つのアクションが必要です。

今回も先にコントローラとリソースを生成します。

bash
$ rails g controller PasswordResets new create edit update
config/routes.rb
# 中略
resources :password_resets, only: [:new, :create, :edit, :update]

パスワード再設定までをつくる手順

以下はパスワード再設定をつくるときの手順です。

  1. パスワード再設定用のビューを作る(new)
  2. トークンとダイジェストを生成し、メールを送信する(create)
  3. メール内のURLにトークンとメールアドレスを忍ばせる
  4. URLをクリックしたらURL内の情報が有効か確認する(edit)
  5. 確認した情報や入力したパスワードが正しければ再設定が完了する(update)

パスワード再設定用のビューを生成(new)

それでは始めていきましょう。
始めに先にビューを完成させてしまいます。
整えるビューは3つです。

  • ログイン画面にパスワード再設定リンク(メール入力)を追加する
  • パスワード再設定画面(メール入力)を作る
  • パスワード再設定画面(パスワード入力)を作る

ログイン画面にリンクを追加

ログイン画面のビューからパスワード再設定にアクセスできるように編集します。

app/views/sessions/new.html.erb
<% provide(:title, "ログイン") %>
<div class="container form-container login-container">
  <div class="row">
    <div class="col">
      <div class="form-logo-img">
        <%= link_to image_tag('lantern_lantern_logo.png', width: 100), root_path, class: "logo-img" %>
      </div>
      <h1 class="form-title">ログイン</h1>
      <%= form_with(scope: :session, url: login_path, local: true) do |form| %>

        <div class="form-group">
          <%= form.email_field :email, class: 'form-control', placeholder: "メールアドレス" %>
        </div>
        <div class="form-group">
          <%= form.password_field :password, class: 'form-control', placeholder: "パスワード" %>
        </div>
        <div class="form-group form-check">
          <%= form.check_box :remember_me, class: 'form-check-input' %>
          <%= form.label :remember_me, '次から保存(ログイン省略)', class: 'form-check-label' %>
        </div>
        <div class="form-group">
          <%= form.submit "ログイン", class: 'btn btn-info btn-lg form-submit' %>
        </div>
      <% end %>
      <p class="form-go-to-signup-or-login">新しくはじめる方は<%= link_to "こちら", signup_path %></p>
      <p class="form-go-to-password-reset">パスワードを忘れた方は<%= link_to "こちら", new_password_reset_path %></p>
    </div>
  </div>
</div>

lantern_lantern_login_with_reset_password.png

パスワード再設定画面(メール入力)を作る

続いてメールを送るまでのパスワード再設定画面を作ります。

app/views/password_resets/new.html.erb
<% provide(:title, "パスワード再設定依頼") %>
<div class="container form-container password_reset-container">
  <div class="row">
    <div class="col">
      <div class="form-logo-img">
        <%= link_to image_tag('lantern_lantern_logo.png', width: 100), root_path, class: "logo-img" %>
      </div>
      <h1 class="form-title">パスワード再設定</h1>
      <%= form_with(scope: :password_reset, url: password_resets_path, local: true) do |form| %>
        <div class="form-group">
          <%= form.email_field :email, class: 'form-control', placeholder: "メールアドレス" %>
        </div>
        <div class="form-group">
          <%= form.submit "送信", class: 'btn btn-info btn-lg form-submit' %>
        </div>
      <% end %>
      <p class="form-go-to-signup-or-login">思い出した方は<%= link_to "こちら", login_path %></p>
    </div>
  </div>
</div>

lantern_lantern_reset_password_1.png

パスワード再設定画面(パスワード入力)を作る

ここはちょっとクセがあります。
なぜならPATCHであるapdateアクションは、何をキーにしてユーザを判別すれば良いのか考える必要があるからです。

メール送信時(GET / #edit)はURL内のメールアドレスをキーにしました。
同じようにするにはフォームでメールアドレスを送信してほしいものです。
しかしユーザ側からすると手間になります。

そこで隠しフィールドを使います。

<%= hidden_field_tag :email, @user.email %>

こんな風に記述すると見えない形でパラメータを送ってくれます。
(params[:email]の形で取得できます)
これを用いつつ、ビューを完成させましょう。

app/views/password_resets/edit.html.erb
<% provide(:title, "パスワード再設定") %>
<div class="container form-container password_reset-container">
  <div class="row">
    <div class="col">
      <div class="form-logo-img">
        <%= link_to image_tag('lantern_lantern_logo.png', width: 100), root_path, class: "logo-img" %>
      </div>
      <h1 class="form-title">パスワード再設定</h1>
      <%= form_with(model: @user, url: password_reset_path(params[:id]), local: true) do |form| %>
        <%= render 'shared/error_messages' %>

        <%= hidden_field_tag :email, @user.email %>
        <div class="form-group">
          <%= form.password_field :password, class: 'form-control', placeholder: "パスワード" %>
        </div>
        <div class="form-group">
          <%= form.password_field :password_confirmation, class: 'form-control', placeholder: "パスワード(再入力)" %>
        </div>
        <div class="form-group">
          <%= form.submit "送信", class: 'btn btn-info btn-lg form-submit' %>
        </div>
      <% end %>
    </div>
  </div>
</div>

lantern_lantern_reset_password_2.png
これでビューは完了しました。

トークンとダイジェストを生成しメールを送信する(create)

続いてはトークンとダイジェストの生成からメール送信までを記述しましょう。
手順は以下の通りです。

  • ダイジェスト用の属性を与える
  • トークンとダイジェストを生成、認証するメソッドを確認する
  • 再設定用トークンとダイジェストを生成、メールを送信するメソッドをつくる

ダイジェスト用の属性を与える

その前にマイグレーションで属性を与えます。
以前の記事と異なるのは、再設定の有効期限を記す属性も加えるという点です。

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

トークンとダイジェストを生成、認証するメソッドを確認する

トークンやダイジェストの生成、認証するメソッドは#9ですでに紹介しています。
すでに#9をお読みの方は飛ばしてください。
それ以外の方は以下のメソッドを追加してください。

app/models/user.rb
class User < ApplicationRecord
  # 中略
  class << self
    # ダイジェストを生成する
    def digest(string)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                    BCrypt::Engine.cost
      BCrypt::Password.create(string, cost: cost)
    end

    # トークンを生成する    
    def new_token
      SecureRandom.urlsafe_base64
    end
  end

  # トークンとダイジェストを認証する
  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end

再設定用トークンとダイジェストを生成、メールを送信するメソッドをつくる

次はメール送信までに必要なメソッドを用意しましょう。
必要なメソッドを列挙します。

  • create_reset_digest → トークンとダイジェストをまとめて生成
  • send_password_reset_email → メールを送信

トークンは仮属性なのでattr_accessorによる記述もお忘れないように。

class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token
  # 中略
  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

  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  private
  # 中略
end

ついcreate_reset_digestをcreate_activation_digestのすぐ下に記述したくなります。
しかしここはprivateメソッドです。
なぜprivateを使い分けているのでしょうか。

理由はこうです。

  • create_activation_digestはこのUserクラス内でしか使いません
  • よって余計なスコープを広げないために、privateメソッドを使用します
  • でもcreate_reset_digestはコントローラで使用します
  • よってcreate_reset_digestはUserクラスを超えるのでprivateには置けません

使い分けられるよう、心がけましょう。

createアクションからメール送信などを行う

それではcreate_reset_digestなどをコントローラで使いましょう。
メール送信に関する部分はcreateアクションに記述します。

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] = "再設定用のURLを入力したメールに送信しました"
      redirect_to root_url
    else
      flash[:danger] = "お使いのメールアドレスは登録されていません"
      render 'new'
    end
  end

  def edit
  end

  def update
  end
end

メールの設定を行う

createアクションでメール送信を行いました。
続いてはメール内容を整えます。

メーラーの生成に関しては#12でご確認ください。

お読みでない方用に、必要なコードのみ紹介します。
#12をお読みの方はこのコードのみ飛ばしてください

bash
$ rails g mailer UserMailer account_activation password_reset
app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: 'noreply@example.com'
  layout 'mailer'
end

本番環境(〇〇は本番環境で自分のアプリを開いたときのURL)

config/environments/production.rb
# 中略
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  host = '〇〇.herokuapp.com'
  config.action_mailer.default_url_options = { host: host }
  ActionMailer::Base.smtp_settings = {
    :address        => 'smtp.sendgrid.net',
    :port           => '587',
    :authentication => :plain,
    :user_name      => ENV['SENDGRID_USERNAME'],
    :password       => ENV['SENDGRID_PASSWORD'],
    :domain         => 'heroku.com',
    :enable_starttls_auto => true
  }

この後からはお読みの方も必要な手順です。
間違えないよう注意していただけたら幸いです。

メールの送信先とタイトルをつくる

送信先とタイトルはUserMailerクラスの仕事です。
記述しましょう。

mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  # 中略
  def password_reset(user)
    @user = user
    mail to: user.email, subject: "【重要】Lantern Lanternよりパスワード再設定のためのメールを届けました"
  end
end

メール内のURLにトークンとメールアドレスを忍ばせる

メールの本文はViewの仕事です。
Viewはhtml版とtext版で2つあるので注意しましょう。
こんな風に記述するとURLにトークンとメールアドレスを仕込むことができます。

app/views/user_mailer/password_reset.html.erb
<h1>Lantern Lantern</h1>
<p><%= @user.name %>さんへ</p>
<p>下記の『パスワードを再設定する』をクリックしてパスワードを再設定してください。</p>
<%= link_to "パスワードを再設定する", edit_password_reset_url(@user.reset_token, email: @user.email) %>
app/views/user_mailer/password_reset.text.erb
<%= @user.name %>さんへ

Lantern Lanternです。下記のリンクからパスワードを再設定してください。
<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>

どうやってURLを指定したのか。
#12でも説明しましたがこちらでも解説します。
URLの指定にはこのようなコードを使用しました。

edit_password_reset_url(@user.reset_token, email: @user.email)

まずはルートを調べてURLのパターンを確認しましょう。

bash
$ rails routes | grep edit_password_reset
edit_password_reset GET /password_resets/:id/edit(.:format)

『:id』と書かれたこの部分。
ここがedit_password_reset_urlの第1引数に対応します。
だからここにトークンを記述しています。

では第2引数は何をしているのでしょう。
第2引数はハッシュを使用するとクエリパラメータを付与します。
具体的には最後(/editの直後)にemailキーとメールアドレスを組み込みます。

結果としてこんなURLが生成されます。

http://www.example.com/password_resets/q5lt38hQDc_959PVoo6b7A/edit?email=foo%40example.com

以上がURLのからくりです。

メールのプレビューをつくる

先ほど作ったメールがどんな感じに作られたか。
確認するためにプレビューが欲しいところです。
まずはspec内にこのような記述をしましょう。

spec/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  # 中略
  # https://〇〇/rails/mailers/user_mailer/password_reset
  def password_reset
    user = User.first
    user.reset_token = User.new_token
    UserMailer.password_reset(user)
  end
end

加えてテスト環境の設定が必要です。
(#12を済ませた方は必要ありません)
#12にも書いていますが、一応ここでも簡易的に紹介します。

config/environments/development.rb
  # Don't care if the mailer can't send.
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = '〇〇'
  config.action_mailer.default_url_options = { host: host, protocol: 'https' }
  config.action_mailer.perform_caching = false

'〇〇'のところは自分の環境によって変更する必要があります。
具体的にはdevelopment環境のURLを挿入します。
(わからない方は#12をご覧ください)

これが終わると以下のURLにアクセスしましょう。
するとプレビューが表示されます。

https://〇〇/rails/mailers/user_mailer/password_reset

lantern_lantern_passowrd_reset_preview.png
以上でプレビューは完了です。

情報が正しいか確認し再設定する(edit, update)

URLがクリックされたらeditアクションを呼び出します。
ここで行うことは3つあります。

  • Userモデルを特定する
  • 存在するか、トークンは正しいのかを確かめる
  • URLが期限切れでないかを確かめる

この3つに関してはupdateアクションでも同じことを行います。
ということはこうした方がスッキリします。

  • 上記3つをメソッド化する
  • editとupdate時に呼び出す

アクションの直前にメソッドを呼び出すにはbefore_actionを使います。

一方updateアクションはフォームにパスワードが入力された時の処理を書きます。
今回は3つのケースに対応します。

  1. 入力されていない時
  2. 無効なパスワードの時
  3. 正しい時

こちらはupdateアクションのみの振る舞いなので、直接書き込みます。
では、これらを踏まえて実装しましょう。

app/controllers/password_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] = "再設定用のURLを入力したメールに送信しました"
      redirect_to root_url
    else
      flash[:danger] = "お使いのメールアドレスは登録されていません"
      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
      @user.update_attribute(:reset_digest, nil)
      flash[:success] = "パスワードの再設定が完了しました"
      redirect_to @user
    else
      render 'edit'
    end
  end

  private

    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end

    def get_user
      @user = User.find_by(email: params[:email])
    end

    def valid_user
      unless @user && @user.activated? && @user.authenticated?(:reset, params[:id])
        flash[:danger] = "無効なURLです。再度メールアドレスを入力してください"
        redirect_to new_password_reset_url
      end
    end

    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "パスワード再設定URLの有効期限が過ぎています。再度メールアドレスを入力してください"
        redirect_to new_password_reset_url
      end
    end
end

1つ言い忘れたことがあります。
それはcheck_expiration内にあるpassword_reset_expired?メソッドです。

期限切れかどうかを判別する処理に関しては、別途Userモデルにメソッドを用意した上で実装しています。

そちらのメソッドを紹介します。

app/models/user.rb
class User < ApplicationRecord
  # 中略
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    # 中略

これでパスワード再設定に関する全ての実装が完了しました。

テストを書く

最後にパスワード再設定に関するテストを完成させます。
いくつかあるので順に見ていきます。

メーラーテストを書く

まずはメーラーのテストです。
メールの内容についてテストします。
なおメール本文に関してはデコードを行って検証しています。

spec/mailers/user_mailer_spec.rb
require "rails_helper"

RSpec.describe UserMailer, type: :mailer do

  let(:user) { create(:user) }

  # 中略
  describe "password_reset" do
    it "renders mails" do
      user.reset_token = User.new_token
      mail = UserMailer.password_reset(user)
      expect(mail.subject).to eq "【重要】Lantern Lanternよりパスワード再設定のためのメールを届けました"
      expect(mail.to).to eq ["michael@example.com"]
      expect(mail.from).to eq ["noreply@example.com"]
      expect(mail.body.encoded.split(/\r\n/).map{|i| Base64.decode64(i)}.join).to include "Michael Example"
      expect(mail.body.encoded.split(/\r\n/).map{|i| Base64.decode64(i)}.join).to include user.reset_token
      expect(mail.body.encoded.split(/\r\n/).map{|i| Base64.decode64(i)}.join).to include CGI.escape(user.email)
    end
  end
end

パスワード変更のテストを書く

メーラー以外のテストを書きます。
まずはRequest specを生成するところから始めましょう。

bash
$ rails g rspec:request password_resets

今回テストする内容は以下の通りです。

createアクション

  • 無効なメールアドレス
  • 有効なメールアドレス

editアクション

  • 無効なメールアドレス
  • 無効なユーザ
  • 無効なトークン
  • 有効なメールアドレス、ユーザ、トークン

updateアクション

  • 無効なパスワード
  • 空のパスワード
  • 期限切れのトークン
  • 有効なパスワード

では記述します。

spec/requests/password_resets_spec.rb
require 'rails_helper'

RSpec.describe "PasswordResets", type: :request do

  let(:user) { create(:user) }

  describe "POST /password_resets" do
    it "is invalid email address" do
      get new_password_reset_path
      expect(request.fullpath).to eq '/password_resets/new'
      post password_resets_path, params: { password_reset: { email: "" } }
      expect(flash[:danger]).to be_truthy
      expect(request.fullpath).to eq '/password_resets'
    end

    it "is valid email address" do
      get new_password_reset_path
      expect(request.fullpath).to eq '/password_resets/new'
      post password_resets_path, params: { password_reset: { email: user.email } }
      expect(flash[:info]).to be_truthy
      expect(flash[:danger]).to be_falsey
      follow_redirect!
      expect(request.fullpath).to eq '/'
    end
  end

  describe "GET /password_resets/:id/edit" do
    it "is invalid email address" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      get edit_password_reset_path(user.reset_token, email: "")
      expect(flash[:danger]).to be_truthy
      follow_redirect!
      expect(request.fullpath).to eq '/password_resets/new'
    end


    it "is invalid user" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      user.toggle!(:activated)
      get edit_password_reset_path(user.reset_token, email: user.email)
      expect(flash[:danger]).to be_truthy
      follow_redirect!
      expect(request.fullpath).to eq '/password_resets/new'
      user.toggle!(:activated)
    end

    it "is invalid token" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      get edit_password_reset_path('wrong token', email: user.email)
      expect(flash[:danger]).to be_truthy
      follow_redirect!
      expect(request.fullpath).to eq '/password_resets/new'
    end

    it "is valid information" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      get edit_password_reset_path(user.reset_token, email: user.email)
      expect(flash[:danger]).to be_falsey
      expect(request.fullpath).to eq "/password_resets/#{user.reset_token}/edit?email=#{CGI.escape(user.email)}"
    end
  end

  describe "PATCH /password_resets/:id" do
    it "is invalid password" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      get edit_password_reset_path(user.reset_token, email: user.email)
      patch password_reset_path(user.reset_token), params: {
        email: user.email,
        user: {
          password: "foobaz",
          password_confirmation: "barquux"
        }
      }
      expect(request.fullpath).to eq "/password_resets/#{user.reset_token}"
    end

    it "is empty password" do
    post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      get edit_password_reset_path(user.reset_token, email: user.email)
      patch password_reset_path(user.reset_token), params: {
        email: user.email,
        user: {
          password: "",
          password_confirmation: ""
        }
      }
      expect(request.fullpath).to eq "/password_resets/#{user.reset_token}"
    end

    it "has expired token" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      user.update_attribute(:reset_sent_at, 3.hours.ago)
      get edit_password_reset_path(user.reset_token, email: user.email)
      patch password_reset_path(user.reset_token), params: {
        email: user.email,
        user: {
          password: "foobaz",
          password_confirmation: "foobaz"
        }
      }
      expect(flash[:danger]).to be_truthy
      follow_redirect!
      expect(request.fullpath).to eq '/password_resets/new'
    end

    it "is valid information" do
      post password_resets_path, params: { password_reset: { email: user.email } }
      user = assigns(:user)
      get edit_password_reset_path(user.reset_token, email: user.email)
      patch password_reset_path(user.reset_token), params: {
        email: user.email,
        user: {
          password: "foobaz",
          password_confirmation: "foobaz"
        }
      }
      expect(flash[:success]).to be_truthy
      expect(is_logged_in?).to be_truthy
      follow_redirect!
      expect(request.fullpath).to eq "/users/1"
    end
  end
end

このテストではassignsを使用するところがあります。

# 該当箇所
user = assigns(:user)
get edit_password_reset_path(user.reset_token, email: "")

これによりFactoryBotのuserではなく、コントローラ内インスタンス変数のuseruserとして取得し直しています。なぜでしょう。

理由は以下の通りです。

  • edit_password_reset_pathの引数に当たるreset_tokenは、attr_accessorによって生成された仮属性です。
  • 仮属性なのでletで生成したFactoryBotのuserにはreset_tokenが存在しません。よってエラーになります。
  • したがってreset_tokenが代入されたPasswordResetsコントローラのインスタンス変数userを使用する必要があります。

というわけで、コントローラのインスタンス変数のuserが必要です。
そこでassignsを使用します。
assignsはコントローラのインスタンス変数を取得します。

そのためにはgem 'rails-controller-testing'が必要です。
(とてもためらいましたが)導入しましょう。

Gemfile
group :development, :test do
+ gem 'rails-controller-testing'
end
bash
$ bundle install

これで問題なく動作します。
テストを走らせてみましょう。

bash
$ rails spec

問題なければ、以上でテストは終了です。
次回はユーザ投稿の表示を行います。

追記:ですます調に統一しました

今回から語尾を「ですます」に統一しました。
情報が統一されて見やすいかなと。
分かりやすい記事になるよう努めます。

やさしい記事の書き方↓
これであなたのQiita記事もランキング入り!?@jnchitoによる編集リクエスト解説(解説動画付き)


前回:#12 ActionMailer, アクティベーション編
次回:#14 ユーザ投稿表示, ページネーション編

aokyo17
rails tutorial → ポートフォリオing. 誰もが経験した初心びくびく20代1年目.. フォローはすぐ返したい厨。
https://komucha.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away