こんな人におすすめ
- プログラミング初心者でポートフォリオの作り方が分からない
- Rails Tutorialをやってみたが理解することが難しい
前回:#12 ActionMailer, アクティベーション編
次回:#14 ユーザ投稿表示, ページネーション編
この記事は、動画を観た時間を記録するアプリのポートフォリオです。
今回はメールでのパスワードを再設定をできるようにします。
今回の流れ
- パスワード再設定のイメージをつかむ
- ビューを作る
- トークンとダイジェストを生成しURL入りメールを送信する
- URLの情報が正しいか確認し再設定する
パスワード再設定のイメージ
パスワード再設定は#12で紹介したアクティベーションと似ています。
ぜひ比較しながらご覧ください。
アクティベーションの時はビューが必要ありませんでした。
しかし今回は__2つのビュー__と__4つのアクション__が必要です。
今回も先にコントローラとリソースを生成します。
$ rails g controller PasswordResets new create edit update
# 中略
resources :password_resets, only: [:new, :create, :edit, :update]
パスワード再設定までをつくる手順
以下はパスワード再設定をつくるときの手順です。
- パスワード再設定用のビューを作る(new)
- トークンとダイジェストを生成し、メールを送信する(create)
- メール内のURLにトークンとメールアドレスを忍ばせる
- URLをクリックしたらURL内の情報が有効か確認する(edit)
- 確認した情報や入力したパスワードが正しければ再設定が完了する(update)
パスワード再設定用のビューを生成(new)
それでは始めていきましょう。
始めに先にビューを完成させてしまいます。
整えるビューは3つです。
- ログイン画面にパスワード再設定リンク(メール入力)を追加する
- パスワード再設定画面(メール入力)を作る
- パスワード再設定画面(パスワード入力)を作る
ログイン画面にリンクを追加
ログイン画面のビューからパスワード再設定にアクセスできるように編集します。
<% 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>
パスワード再設定画面(メール入力)を作る
続いてメールを送るまでのパスワード再設定画面を作ります。
<% 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>
パスワード再設定画面(パスワード入力)を作る
ここはちょっとクセがあります。
なぜならPATCHであるapdateアクションは、何をキーにしてユーザを判別すれば良いのか考える必要があるからです。
メール送信時(GET / #edit)はURL内のメールアドレスをキーにしました。
同じようにするにはフォームでメールアドレスを送信してほしいものです。
しかしユーザ側からすると手間になります。
そこで隠しフィールドを使います。
<%= hidden_field_tag :email, @user.email %>
こんな風に記述すると見えない形でパラメータを送ってくれます。
(params[:email]の形で取得できます)
これを用いつつ、ビューを完成させましょう。
<% 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>
トークンとダイジェストを生成しメールを送信する(create)
続いてはトークンとダイジェストの生成からメール送信までを記述しましょう。
手順は以下の通りです。
- ダイジェスト用の属性を与える
- トークンとダイジェストを生成、認証するメソッドを確認する
- 再設定用トークンとダイジェストを生成、メールを送信するメソッドをつくる
ダイジェスト用の属性を与える
その前にマイグレーションで属性を与えます。
以前の記事と異なるのは、再設定の有効期限を記す属性も加えるという点です。
$ rails g migration add_reset_to_users reset_digest:string reset_sent_at:datetime
$ rails db:migrate
トークンとダイジェストを生成、認証するメソッドを確認する
トークンやダイジェストの生成、認証するメソッドは#9ですでに紹介しています。
すでに__#9をお読みの方は飛ばしてください。__
それ以外の方は以下のメソッドを追加してください。
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アクションに記述します。
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をお読みの方はこのコードのみ飛ばしてください
$ rails g mailer UserMailer account_activation password_reset
class ApplicationMailer < ActionMailer::Base
default from: 'noreply@example.com'
layout 'mailer'
end
本番環境(〇〇は本番環境で自分のアプリを開いたときのURL)
# 中略
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クラスの仕事です。
記述しましょう。
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にトークンとメールアドレスを仕込むことができます。
<h1>Lantern Lantern</h1>
<p><%= @user.name %>さんへ</p>
<p>下記の『パスワードを再設定する』をクリックしてパスワードを再設定してください。</p>
<%= link_to "パスワードを再設定する", edit_password_reset_url(@user.reset_token, email: @user.email) %>
<%= @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のパターンを確認しましょう。
$ 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内にこのような記述をしましょう。
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にも書いていますが、一応ここでも簡易的に紹介します。
# 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
情報が正しいか確認し再設定する(edit, update)
URLがクリックされたら__editアクション__を呼び出します。
ここで行うことは3つあります。
- Userモデルを特定する
- 存在するか、トークンは正しいのかを確かめる
- URLが期限切れでないかを確かめる
この3つに関してはupdateアクションでも同じことを行います。
ということはこうした方がスッキリします。
- 上記3つをメソッド化する
- editとupdate時に呼び出す
アクションの直前にメソッドを呼び出すにはbefore_actionを使います。
一方__updateアクション__はフォームにパスワードが入力された時の処理を書きます。
今回は3つのケースに対応します。
- 入力されていない時
- 無効なパスワードの時
- 正しい時
こちらはupdateアクションのみの振る舞いなので、直接書き込みます。
では、これらを踏まえて実装しましょう。
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モデルにメソッドを用意した上で実装__しています。
そちらのメソッドを紹介します。
class User < ApplicationRecord
# 中略
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
# 中略
これでパスワード再設定に関する全ての実装が完了しました。
テストを書く
最後にパスワード再設定に関するテストを完成させます。
いくつかあるので順に見ていきます。
メーラーテストを書く
まずはメーラーのテストです。
メールの内容についてテストします。
なおメール本文に関してはデコードを行って検証しています。
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を生成するところから始めましょう。
$ rails g rspec:request password_resets
今回テストする内容は以下の通りです。
createアクション
- 無効なメールアドレス
- 有効なメールアドレス
editアクション
- 無効なメールアドレス
- 無効なユーザ
- 無効なトークン
- 有効なメールアドレス、ユーザ、トークン
updateアクション
- 無効なパスワード
- 空のパスワード
- 期限切れのトークン
- 有効なパスワード
では記述します。
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__ではなく、__コントローラ内インスタンス変数のuser__を__userとして取得__し直しています。なぜでしょう。
理由は以下の通りです。
- edit_password_reset_pathの引数に当たる__reset_token__は、attr_accessorによって生成された__仮属性__です。
- 仮属性なのでletで生成した__FactoryBotのuserにはreset_tokenが存在しません__。よって__エラー__になります。
- したがってreset_tokenが代入された__PasswordResetsコントローラのインスタンス変数user__を使用する必要があります。
というわけで、コントローラのインスタンス変数のuserが必要です。
そこでassignsを使用します。
assignsはコントローラのインスタンス変数を取得します。
そのためにはgem 'rails-controller-testing'が必要です。
(とてもためらいましたが)導入しましょう。
group :development, :test do
+ gem 'rails-controller-testing'
end
$ bundle install
これで問題なく動作します。
テストを走らせてみましょう。
$ rails spec
問題なければ、以上でテストは終了です。
次回はユーザ投稿の表示を行います。
追記:ですます調に統一しました
今回から語尾を「ですます」に統一しました。
情報が統一されて見やすいかなと。
分かりやすい記事になるよう努めます。
やさしい記事の書き方↓
これであなたのQiita記事もランキング入り!?@jnchitoによる編集リクエスト解説(解説動画付き)