#パスワードの再設定
■第12章
本章では、ここで生成したメイラーにリソースとデータモデルを追加して、パスワードの再設定を実現していく。
##12.1 PasswordResetsリソース
必要なデータをUserモデルに追加していく。11章ではedit
アクションのみだったが、今回はフォームが必要なのでnew
アクションとedit
アクションが必要。
###12.1.1 PasswordResetsコントローラ
パスワード設定用のコントローラを作成する。今回はビューも扱うため、new
アクションとedit
アクションも一緒に生成する。
$ rails generate controller PasswordResets new edit --no-test-framework
新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、new
、create
、edit
、update
のルーティングも用意する。
以下のコードを追加。
resources :password_resets, only: [:new, :create, :edit, :update]
パスワード再設定画面へのリンクを追加して、テストもGREENに。
###12.1.2 新しいパスワードの設定
トークン用の仮想的な属性とそれに対応するダイジェストを用意する。
また、トークンを平文でデータベースに保存してしまうとセキュリティ上の問題があるため、必ずダイジェストを利用する。
再設定用のリンクはなるべく短時間で期限切れになるようにする。
reset_digest
属性とreset_sent_at
属性をUserモデルに追加。
$ rails generate migration add_reset_to_users reset_digest:string \
> reset_sent_at:datetime
その後はマイグレーション。
ログインフォームのコードを改変して、下記を作成。form_for
で扱うリソースとURLが異なっている点と、パスワード属性が省略されている点が違う。
<% 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>
###12.1.3 createアクションでパスワード再設定
フォームから送信を行なった後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要がある。
それに続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する。
送信が無効の場合、ログインと同様に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
パスワード再設定の属性を設定する。トークンを生成し、それをダイジェストに変換して設定。時間も設定する。
ここまでで、無効なメールアドレスを入力したときの動作が完成。
有効なメールアドレスを入力をした場合の動作を次からやる。
##12.2 パスワード再設定のメール送信
パスワード再設定に関するメールを送信する部分を作っていく。
Userメイラーを生成したときに、デフォルトのpassword_reset
メソッドもまとめて生成されているはず。
###12.2.1 パスワード再設定のメールとテンプレート
パスワード再設定のリンクをメール送信する。
password_reset
メソッドを作成。
def password_reset(user)
@user = user
mail to: user.email, subject: "Password reset"
end
テキストとHTMLも改変する。
プレビューも完成させる。
# 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
###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 パスワードを再設定する
edit
アクションの実装を進める。統合テストも書いていく。
###12.3.1 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>
edit
とupdate
でユーザー情報を取得して、正しいユーザーでなければルートページに飛ばす。
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
###12.3.2 パスワードを更新する
今回はフォームから新しいパスワードを送信するようになっています。したがって、フォームからの送信に対応するupdateアクションが必要になる。
updateアクションでは以下の4つを考慮する
①パスワード再設定の有効期限が切れていないか
②無効なパスワードであれば失敗させる
③新しいパスワードが空文字列になっていないか
④新しいパスワードが正しければ更新する
①、②、④はbefore_action
で対応。
③がちょっと小難しい。今回は@user
オブジェクトにエラーメッセージを追加する方法をとってみる。
@user.errors.add(:password, :blank)
こう書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれる。
パスワード再設定の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] # (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
reset_sent_at < 2.hours.ago
これで「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前 (早い)」 という意味になる。
###12.3.3 パスワードの再設定をテストする
送信に成功した場合と失敗した場合の統合テストを作成する。
テストファイル作成。
$ rails generate integration_test password_resets
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
# 無効なパスワードとパスワード確認
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
end
これでテストもGREENに。
##感想
12章はすんなり終えることができました。ただ、パスワードの再設定の実装だけでも、ゼロからやるとなると苦労しそうだなーという印象でした。