はじめに
パスワードを忘れた時のパスワードの再設定ができるようにする。
12.1 PasswordResetsリソース
12.1.1 PasswordResetsコントローラ
再設定するフォームのビューを描画するためのnewアクションとeditアクションを作成する。
$ rails generate controller PasswordResets new edit --no-test-framework
--no-test-framework
テストに関する部分は自動生成しないようにするオプション。
resources :password_resets, only: [:new, :create, :edit, :update]
:new
, :create
新しいパスワードを再設定するためのフォーム
:edit
, :update
Userモデル内のパスワードを変更するためのフォーム
演習 1
この時点で、テストスイートが green になっていることを確認してみましょう。
GREEN
演習 2
表 12.1の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。ヒント: アカウント有効化で行った演習(11.1.1.1)と同じ理由です。
メール本文のURLからアクセスため、_urlを使う必要がある。
12.1.2 新しいパスワードの設定
パスワードの再設定では必ずダイジェストを使うように(トークンをハッシュ化してDBに保存するように)する。
演習 1
リスト 12.4のform_withメソッドで、@password_resetではなく:password_resetを使っている理由を考えてみましょう。
Modelを作っていないから。
<%= form_with(url: password_resets_path, scope: :password_reset,
local: true) do |f| %>
どこに対して(url: password_resets_path
)、どんな内容を渡すか(scope: :password_reset
)を記述すれば必ずしもModelは必要ではない。
12.1.3 createアクションでパスワード再設定
class PasswordResetsController < ApplicationController
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
# 小文字にしたメールアドレスをDBから検索して@userに代入
if @User
# @userにメールアドレスが渡されているか
@user.create_reset_digest
# create_reset_digestをDBに送る
@user.send_password_reset_email
# password_reset_emailを送るユーザーへ送る
flash[:info] = "Email sent with password reset instructions"
# フラッシュメッセージを表示
redirect_to root_url
# root_urlへリダイレクト
else
flash.now[:danger] = "Email address not found"
# フラッシュメッセージを表示
render 'new'
# /password_resets/newをレンダリング
end
end
def edit
end
end
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
# カラムに保存されない:reset_tokenが使えるようになる
.
.
.
# パスワード再設定の属性を設定する
def create_reset_digest
self.reset_token = User.new_token
# 新しいトークンを作成して変数に代入
update_attribute(:reset_digest, User.digest(reset_token))
# :reset_digestにUser.digest(reset_token)を保存
update_attribute(:reset_sent_at, Time.zone.now)
# :reset_sent_atにTime.zone.nowを保存
end
# パスワード再設定のメールを送信する
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
# メールの送信
end
演習 1
試しに有効なメールアドレスをフォームから送信してみましょう(図 12.6)。どんなエラーメッセージが表示されたでしょうか?
ArgumentError in PasswordResetsController#create
「PasswordResetsController#createに引数のエラーがあります」
wrong number of arguments (given 1, expected 0)
「引数の数を間違えていますよ(引数は1つあるはずなのに、引数が0ですよ)」
演習 2
コンソールに移り、先ほどの演習課題で送信した結果、(エラーと表示されてはいるものの)該当するuserオブジェクトにはreset_digestとreset_sent_atがあることを確認してみましょう。また、それぞれの値はどのようになっていますか?
>> u = User.find_by(id:1)
(107.0ms) SELECT sqlite_version(*)
User Load (1.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2021-03-01 12:03:59", updated_at: "2021-03-06 08:54:42", password_digest: [FILTERED], remember_digest: nil, admin: true, activation_digest: "$2a$12$xQmA3Nr.gMMEZHytwwPZmu86JpuupiFP9KNZ/RdLl/I...", activated: true, activated_at: "2021-03-01 12:03:58", reset_digest: "$2a$12$EQBfrsnnSUphKApEMZ.PBuV0NxTd/x/lZpQZ5cJ6xm9...", reset_sent_at: "2021-03-06 08:54:42">
reset_digest
にはactivation_digestが、reset_sent_at
にはメールを送信した日時?が入っている。
12.2 パスワード再設定のメール送信
パスワード再設定に関するメールを送信する部分を作成する。
12.2.1 パスワード再設定のメールとテンプレート
演習 1
ブラウザから、送信メールのプレビューをしてみましょう。「Date」の欄にはどんな情報が表示されているでしょうか?
送信ボタンを押した日時
演習 2
パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。
確認のみなので省略
演習 3
コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認してみましょう。
reset_digest
: 値が入っている
reset_sent_at
: 送信ボタンを押した日時が入っている
12.2.2 送信メールのテスト
演習 1
メイラーのテストだけを実行してみてください。このテストは green になっているでしょうか?
GREEN
演習 2
リスト 12.12にある2つ目のCGI.escapeを削除すると、テストが red になることを確認してみましょう。
RED
12.3 パスワードを再設定する
PasswordResetsコントローラのeditアクションの実装を進めていく。
12.3.1 editアクションで再設定
演習 1
12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて、図 12.11のように表示されるか確かめてみましょう。
表示されました。
演習 2
先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?
Unknown action
「アクションが見つかりません」
The action 'update' could not be found for PasswordResetsController
「アクション 'update' がPasswordResetsControllerに見つかりませんでした。」
12.3.2 パスワードを更新する
updateアクションを作成する
- パスワード再設定の有効期限が切れていないか
- 無効なパスワードであれば失敗させる(失敗した理由も表示する)
- 新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった) 上記3つの条件を満たしていれば更新する。
演習 1
12.2.1.1で得られたリンク(Railsサーバーのログから取得)をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?
The form contains 1 error.
Password confirmation doesn't match Password
演習 2
コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう(図 12.13)。パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。ヒント: 新しい値はuser.reloadを通して取得する必要があります。
確認のみなので省略。
12.3.3 パスワードの再設定をテストする
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
# new_password_reset_pathにGETリクエスト
assert_template 'password_resets/new'
# /password_resets/newを描画
assert_select 'input[name=?]', 'password_reset[email]'
#
# メールアドレスが無効
post password_resets_path, params: { password_reset: { email: "" } }
# password_resets_path に無効なメールアドレスでPOSTリクエスト
assert_not flash.empty?
# フラッシュが表示されていない => true
assert_template 'password_resets/new'
# password_resets/newが描画されているか
# メールアドレスが有効
post password_resets_path,
params: { password_reset: { email: @user.email } }
# password_resets_pathに有効なメールアドレスでPOSTリクエスト
assert_not_equal @user.reset_digest, @user.reload.reset_digest
# @user.reset_digestと@user.reload.reset_digestは同じではない
assert_equal 1, ActionMailer::Base.deliveries.size
# 1とActionMailer::Base.deliveries.sizeは同じ
assert_not flash.empty?
# フラッシュが表示されている => false
assert_redirected_to root_url
# root_urlにリダイレクト
# パスワード再設定フォームのテスト
user = assigns(:user)
# メールアドレスが無効
get edit_password_reset_path(user.reset_token, email: "")
# edit_password_reset_pathに無効なメールアドレスでGETリクエスト
assert_redirected_to root_url
# root_urlにリダイレクト
# 無効なユーザー
user.toggle!(:activated)
# :activated(無効)を反転して有効にする
get edit_password_reset_path(user.reset_token, email: user.email)
# edit_password_reset_pathに無効なユーザーでGETリクエスト
assert_redirected_to root_url
# root_urlにリダイレクト
user.toggle!(:activated)
# :activated(有効)を反転して無効にする
# メールアドレスが有効で、トークンが無効
get edit_password_reset_path('wrong token', email: user.email)
# edit_password_reset_pathに無効なトークンと有効なメールアドレスでGETリクエスト
assert_redirected_to root_url
# root_urlにリダイレクト
# メールアドレスもトークンも有効
get edit_password_reset_path(user.reset_token, email: user.email)
# edit_password_reset_pathに有効なトークンと有効なメールアドレスでGETリクエスト
assert_template 'password_resets/edit'
# password_resets/editが描画される
assert_select "input[name=email][type=hidden][value=?]", user.email
# <input id="email" name="email" type="hidden" value="メールアドレス" />があるか
# 無効なパスワードとパスワード確認
patch password_reset_path(user.reset_token),
params: { email: user.email,
user: { password: "foobaz",
password_confirmation: "barquux" } }
# password_reset_pathに無効なパスワードと確認用パスワードがイコールでない状態でPATCHリクエスト
assert_select 'div#error_explanation'
# <div id="error_explanation">があるか
# パスワードが空
patch password_reset_path(user.reset_token),
params: { email: user.email,
user: { password: "",
password_confirmation: "" } }
# password_reset_pathに無効なパスワードと確認用パスワードが空白の状態でPATCHリクエスト
assert_select 'div#error_explanation'
# <div id="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?
# フラッシュが表示されflase
assert_redirected_to user
# user詳細ページにリダイレクト
end
end
演習 1
リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 12.20に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう(これでデータベースへの問い合わせが1回で済むようになります)。また、変更後にテストを実行し、 green になることも確認してください。ちなみにリスト 12.20にあるコードには、前章の演習(リスト 11.39)の解答も含まれています。
演習 2
リスト 12.21のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐(リスト 12.16)を統合テストで網羅してみましょう(12.21 のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです)。期限切れをテストする方法はいくつかありますが、リスト 12.21でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます(なお、大文字と小文字は区別されません)。
test "expired token" do
get new_password_reset_path
# new_password_reset_pathにGETリクエスト
post password_resets_path,
params: { password_reset: { email: @user.email } }
# password_resets_pathにPOSTリクエスト
@user = assigns(:user)
# @userに@userを代入
@user.update_attribute(:reset_sent_at, 3.hours.ago)
# @userの:reset_sent_atを3.hours.agoに上書き
patch password_reset_path(@user.reset_token),
params: { email: @user.email,
user: { password: "foobar",
password_confirmation: "foobar" } }
# password_reset_pathをに有効な情報でPATCHリクエスト
assert_response :redirect
# レスポンスはリダイレクトになるはず
follow_redirect!
# editページにリダイレクト
assert_match /expired/i, response.body
# レスポンスの本文に「expired」という語があるかどうかでチェック
end
演習 3
2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。しかし、もっと良くする方法はまだあります。例えば、公共の(または共有された)コンピューターでパスワード再設定が行われた場合を考えてみてください。仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを表示させ、パスワードを更新してしまうことができてしまいます(しかもそのままログイン機構まで突破されてしまいます!)。この問題を解決するために、リスト 12.22のコードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう5 。
動作確認のみなので省略。
演習 4
リスト 12.18に1行追加し、1つ前の演習課題に対するテストを書いてみましょう。ヒント: リスト 9.25のassert_nilメソッドとリスト 11.33のuser.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。
test "password resets" do
.
.
.
assert_nil user.reload['reset_digest']
# user.reload['reset_digest']がnil => true
end
12.4 本番環境でのメール送信(再掲)
演習 1
production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?
動作確認のみなので省略。
演習 2
メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。ヒント: ターミナルからheroku logsコマンドを実行してみましょう。
動作確認のみなので省略。
演習 3
アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正しくパスワードの再設定ができたでしょうか?
動作確認のみなので省略。
さいごに
内容が第11章と似ているので良い復習になりました。習い損じを知る良い機会になりました。
次はTwitterライクのマイクロポスト機能を実装していくということでワクワクしています!
最後まで頑張ろう!