12章 パスワードの再設定
パスワードを忘れた時のパスワードの再設定に取り組みます。
手順は
1、サンプルアプリケーションのログインフォームに「forgot password」リンクを追加します
2、この「forgot password」リンクをクリックするとフォームが表示され、そこにメールアドレスを入力、パスワード再設定用のリンクが記載されたメールを送信
3、この再設定用のリンクをクリックすると、ユーザーのパスワードを再設定用フォームを表示
4、有効な情報な場合更新
アカウント有効化の際と似ていて、PasswordResetsリソースを作成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となります。
12.1 PasswordResetsリソース
前章と同様に、今回も新たなモデルは作らずに、代わりに必要なデータ (再設定用のダイジェストなど) をUserモデルに追加していく形で進めていきましょう。
有効化のときはeditアクションだけを取り扱いましたが、今回はパスワードを再設定するフォームが必要なので、ビューを描画するためのnewアクションとeditアクションが必要になります。また、それぞれのアクションに対応する作成用/更新用のアクションも最終的なRESTfulなルーティングには必要になります。
$ git checkout -b password-reset
12.1.1 PasswordResetsコントローラ
前章同様コントローラ生成から。newアクションとeditアクションも一緒に生成
$ rails generate controller PasswordResets new edit --no-test-framework
上のコマンドではテストを生成しないというオプションを指定していることにご注目ください。これはコントローラの単体テストをする代わりに、今回は統合テストでカバーしていくから。
ということでそれぞれのアクションに対応する作成用/更新用のアクションをふくめたnew、create、edit、updateのルーティングを設定
Rails.application.routes.draw do
root 'static_pages#home'
get '/help', to: 'static_pages#help'
get '/about', to: 'static_pages#about'
get '/contact', to: 'static_pages#contact'
get '/signup', to: 'users#new'
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
resources :users
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
end
パスワード再設定へのリンクをログイン画面に追加
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) 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>
12.1.2 新しいパスワードの設定
今まで同様重要な値を取り扱うのでパスワードの再設定では必ずダイジェストを使うようにしてください。
セキュリティ上の注意点はもう1つあります。それは再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。
そのために、再設定メールの送信時刻も記録する必要があります。以上の背景に基づいて、reset_digest属性とreset_sent_at属性をUserモデルに追加
$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
$ rails db:migrate
再設定用のビューを作成します。
共通部分が多いログインページのコードを参考に以下の様にする
<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:password_reset, url: password_resets_path) do |f| %> <--注目
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.submit "Submit", class: "btn btn-primary" %>
<% end %>
</div>
</div>
重要な違いとして、form_forで扱うリソースとURLが異なっている点
なぜ@password_resetではなく:password_resetを使っているのでしょうか?→(参考:https://ja.stackoverflow.com/questions/18099/rails%E3%81%AEform-for%E3%81%AB%E3%82%B7%E3%83%B3%E3%83%9C%E3%83%AB%E3%82%92%E4%B8%8E%E3%81%88%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AF%E3%81%A9%E3%81%AE%E3%82%88%E3%81%86%E3%81%AA%E3%81%A8%E3%81%8D%E3%81%8B%EF%BC%89)
12.1.3 createアクションでパスワード再設定
newフォームから有効なメールアドレスの送信を行なった後、
1、メールアドレスをキーとしてユーザーをデータベースから見つけ、
2、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要があります。
3、それに続いてルートURLにリダイレクトし、
4、フラッシュメッセージをユーザーに表示します。
elseで
送信が無効の場合は、ログインと同様にnewページを出力してflash.nowメッセージを表示します
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
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
before_save :downcase_email
before_create :create_activation_digest
.
.
.
# アカウントを有効にする
def activate
update_attribute(:activated, true)
update_attribute(:activated_at, Time.zone.now)
end
# 有効化用のメールを送信する
def send_activation_email
UserMailer.account_activation(self).deliver_now
end
# パスワード再設定の属性を設定する
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)<--update_columnsに変更しよう
end
# パスワード再設定のメールを送信する
def send_password_reset_email
UserMailer.password_reset(self).deliver_now
end
private
# メールアドレスをすべて小文字にする
def downcase_email
self.email = email.downcase
end
# 有効化トークンとダイジェストを作成および代入する
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
end
end
この時点でのアプリケーションは、無効なメールアドレスを入力した場合に正常に動作します。
正しいメールアドレスを送信した場合にはまだメイラーが実装されてないのでエラーを表示します。
アプリケーションが正常に動作するためには、パスワード再設定のメイラーメソッドを定義する必要があります。(該当するuserオブジェクトにはreset_digestとreset_sent_atがある)
12.2 パスワード再設定のメール送信
11章でUserメイラー (app/mailers/user_mailer.rb) を生成したときに、デフォルトのpassword_resetメソッドもまとめて生成されているはず
12.2.1 パスワード再設定のメールとテンプレート
11.3.3では、UserメイラーにあるコードをUserモデルに移すリファクタリングを行いました。同様のリファクタリング作業を、パスワード再設定に対しても行っていきましょう
UserMailer.password_reset(self).deliver_now
上のコードの実装に必要なメソッドは、11.2で実装したアカウント有効化用メイラーメソッドとほぼ同じです。
最初にUserメイラーにpassword_resetメソッドを作成し、続いて、2つ自動作成されているテンプレートのテキストメールのテンプレート とHTMLメールのテンプレート をそれぞれ定義します。
class UserMailer < ApplicationMailer
def account_activation(user)
@user = user
mail to: user.email, subject: "Account activation"
end
def password_reset(user)
@user = user
mail to: user.email, subject: "Password reset"
end
end
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.
<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>
次にアカウント有効化メールの場合 (11.2) と同様、Railsのメールプレビュー機能でパスワード再設定のメールをプレビューしましょう
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/account_activation
def account_activation
user = User.first
user.activation_token = User.new_token
UserMailer.account_activation(user)
end
# Preview this email at
# http://localhost:3000/rails/mailers/user_mailer/password_reset
def password_reset
user = User.first
user.reset_token = User.new_token
UserMailer.password_reset(user)
end
end
一度URLを入力して確認しておきましょう
有効化のときと同様いまは有効な情報を送信するとサーバーログに表示されます。
コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。オブジェクトを見つけたら、そのオブジェクトが持つreset_digestとreset_sent_atの値を確認しておきましょう。
12.2.2 送信メールのテスト
require 'test_helper'
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
user = users(:michael)
user.activation_token = User.new_token
mail = UserMailer.account_activation(user)
assert_equal "Account activation", mail.subject
assert_equal [user.email], mail.to
assert_equal ["noreply@example.com"], mail.from
assert_match user.name, mail.body.encoded
assert_match user.activation_token, mail.body.encoded
assert_match CGI.escape(user.email), mail.body.encoded
end
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
end
GREEN
12.3 パスワードを再設定する
newビューからcreateアクション経由でメールの送信が完了したので
ここからは添付したURLつまりEdit関連を実装、そのあとupdateアクションを完了させて
再設定を完結させる
12.3.1 editアクションで再設定
ユーザーの編集フォームと似ていますが、今回はパスワード入力フィールドと確認用フィールドだけで十分です。
ただし、今回の作業は少しだけ面倒な点があります。
というのも、メールアドレスをキーとしてユーザーを検索するためには、editアクションとupdateアクションの両方でメールアドレスが必要になるからです。例のメールアドレス入りリンクのおかげで、editアクションでメールアドレスを取り出すことは問題ありません。しかしフォームを一度送信してしまうと、この情報は消えてしまいます。この値はどこに保持しておくのがよいのでしょうか。今回はこのメールアドレスを保持するため、隠しフィールドとしてページ内に保存する手法をとります。これにより、フォームから送信したときに、他の情報と一緒にメールアドレスが送信されるようになります。
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user, url: password_reset_path(params[:id])) 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]に保存されます
前回までの (f.hidden_field)のように変数fを加えてしまうとparams[:user][:email] に保存されてしまうからです。
これはコントローラ内の@user = User.find_by(email: params[:email])
で対応スルユーザーをメールアドレスで検索する
際に応用されるわけだ
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
注目・・・ここでも抽象化したauthenticated?メソッドを使って、このユーザーが正当なユーザーである (ユーザーが存在する、有効化されている、認証済みである) ことを確認します。editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、beforeフィルタを使って@userの検索とバリデーションを行います
12.3.2 パスワードを更新する(updateアクション)
このupdateアクションでは、次の4つのケースを考慮する必要があります。
1、パスワード再設定の有効期限が切れていないか
2、無効なパスワードであれば失敗させる (失敗した理由も表示する)
3、新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4、新しいパスワードが正しければ、更新する
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
before_action :check_expiration, only: [:edit, :update] # (1) への対応
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
def update
if params[:user][:password].empty? # (3) への対応
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(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
# beforeフィルタ
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
# トークンが期限切れかどうか確認する
def check_expiration
if @user.password_reset_expired? <--注目
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
end
(1)の対策・・・beforeフィルターで期限切れかどうかメソッドを呼び出している。
(2)(3)(4)の対策・・・if文の条件分岐でそれぞれコードに落とし込んでいる
この中でも小難しいのが(3)パスワードが空文字だった場合の処理です。というのも、以前Userモデルを作っていたときに、パスワードが空でも良い (allow_nil)という実装をしたからです。したがって、このケースについては明示的にキャッチするコードを追加する必要があります
、次のようにerrors.addを使ってエラーメッセージを追加します。
@user.errors.add(:password, :blank)
このようにrailsに定義されているerrorsメソッドで書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれるようになります。
注目・・・@user.password_reset_expired?
このメソッドをUserモデルで定義していきましょう。このメソッドではパスワード再設定の期限を設定して、2時間以上パスワードが再設定されなかった場合は期限切れとする処理を行います。
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
注釈:上の < 記号を「〜より少ない」と読んでしまうと、「パスワード再設定メール送信時から経過した時間が、2時間より少ない場合」となってしまい、困惑してしまうので注意してください。ここで行っている処理は、「少ない」ではなく「早い」と捉えると理解しやすいです。つまり、< 記号を「〜より早い時刻」と読んでください。こうすると「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前 (早い) の場合」となり、 期待どおりの条件となります。
これでupdateアクションが作動します。
12.3.3 パスワードの再設定をテストする
この項では、2つ (または期限切れを含めて3つ) の分岐、つまり送信に成功した場合と失敗した場合の統合テストを作成します
$ rails generate integration_test password_resets
invoke test_unit
create test/integration/password_resets_test.rb
テストする手順は、アカウント有効化のテスト (リスト 11.33) と多くの共通点がありますが、テストの冒頭部分には次のような違いがあります。
最初に「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'
# メールアドレスが無効
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<--注目1
# 無効なパスワードとパスワード確認
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
test "expired token" do
get new_password_reset_path
post password_resets_path,
params: { password_reset: { email: @user.email } }
@user = assigns(:user)
@user.update_attribute(:reset_sent_at, 3.hours.ago)
patch password_reset_path(@user.reset_token),
params: { email: @user.email,
user: { password: "foobar",
password_confirmation: "foobar" } }
assert_response :redirect
follow_redirect!
assert_match ”expired”, response.body<--注目2
end
end
end
注目1・・・assert_select "input[name=email][type=hidden][value=?]", user.email
このコードは、inputタグに正しい名前、type="hidden"、メールアドレスがあるかどうかを確認します。
→<input id="email" name="email" type="hidden" value="michael@example.com" />
注目2・・・response.bodyは、そのページのHTML本文をすべて返すメソッドです。ここではassert_matchで全文に「expired」という語があるかどうかでチェックしている
GREEN
パスワード再設定が成功したら二度と同じリクエストを通さない
2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。しかし、例えば公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを表示させ、パスワードを更新してしまうことができてしまいます (しかもそのままログイン機構まで突破されてしまいます!)。
この問題を解決するために、コードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう
class PasswordResetsController < ApplicationController
.
.
.
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] = "Password has been reset."
redirect_to @user
else
render 'edit'
end
end
.
.
.
end
テストにも追加
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:michael)
end
test "password resets" do
・
・
assert_redirected_to user
assert_nil user.reload['reset_digest'] <--追加
end