Edited at

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


こんな人におすすめ


  • プログラミング初心者でポートフォリオの作り方が分からない

  • Rails Tutorialをやってみたが理解することが難しい

前回:#12 ActionMailer, アクティベーション編

次回:準備中


こんなことが分かる


  • パスワードを再設定させる方法

  • トークンとダイジェストを生成、認証する方法

  • メーラーの使い方

  • RSpecでコントローラのインスタンス変数を用いる方法


今回の流れ


  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による編集リクエスト解説(解説動画付き)