第12章パスワードの再設定
全体の流れは次のとおりです。
- ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける。
- 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応する再設定ダイジェストを生成する。
- 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。
- ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)。
- 認証に成功したら、パスワード変更用のフォームをユーザーに表示する。
12.1 PasswordResetsリソース
12.1.1 PasswordResetsコントローラ
今回はビューも扱うので、newアクションとeditアクションも一緒に生成している点に注意してください。
rails generate controller PasswordResets new edit --no-test-framework
新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、
new、create、edit、updateのルーティングも用意しましょう。
この変更は、前回と同様にルーティングファイルのresources行で行います。
パスワード再設定用リソースを追加する
config/routes.rb
resources :password_resets, only: [:new, :create, :edit, :update] HTTPリクエスト URL Action 名前付きルート GET /password_resets/new new new_password_reset_path POST /password_resets create password_resets_path GET /password_resets/<token>/edit edit edit_password_reset_url(token) PATCH /password_resets/<token> update password_reset_url(token)
PasswordResetsリソースで提供されるRESTfulルーティング
パスワード再設定画面へのリンクを追加する
app/views/sessions/new.html.erb
<% 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>
演習
1:この時点で、テストスイートが greenになっていることを確認してみましょう
rails test
2:名前付きルートでは、_pathではなく_urlを使うがそれはなぜか?
トークンのURLは絶対パス(https://〜)を使用する必要があるからだよ
12.1.2 新しいパスワードの設定
パスワードの再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意していきます。
もしトークンをハッシュ化せずに (つまり平文で)データベースに
保存してしまうとすると、
攻撃者によってデータベースからトークンを読み出されたとき、
セキュリティ上の問題が生じます。
パスワードの再設定では必ずダイジェストを使うようにしてください。
セキュリティ上の注意点はもう1つあります。
それは再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。
rails generate migration add_reset_to_users reset_digest:string \ reset_sent_at:datetime
rails db:migrate
新しいパスワード再設定画面ビュー
app/views/password_resets/new.html.erb
<% 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>
演習
1:form_forメソッドでは、なぜ@password_resetではなく:password_resetを使っているのでしょうか?考えてみてください。
シンボルを使ってフォームをするとRailsが自動で送信先に値を割り当ててくれるから。
12.1.3 create
アクションでパスワード再設定
メールアドレスをキーとしてユーザーをデータベースから見つけ、
パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を
更新する必要があります。それに続いてルートURLにリダイレクトし、
フラッシュメッセージをユーザーに表示します。送信が無効の場合は、
ログインと同様にnewページを出力してflash.nowメッセージを表示します。
パスワード再設定用の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] = "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
Userモデル内のコードは、before_createコールバック内で
使わるcreate_activation_digestメソッドと似ています
Userモデルにパスワード再設定用メソッドを追加する
app/models/user.rb
class User < ApplicationRecord attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email before_create :create_activation_digest . . . #中略 # パスワード再設定の属性を設定する 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) end # パスワード再設定のメールを送信する def send_password_reset_email UserMailer.password_reset(self).deliver_now end private #後略 end
演習
1:試しに有効なメールアドレスをフォームから送信してみましょう
どんなエラーメッセージが表示されたでしょうか?
ArgumentError in PasswordResetsController#create wrong number of arguments (given 1, expected 0)
2:コンソールに移り、先ほどの演習課題で送信した結果、
(エラーと表示されてはいるものの) 該当するuserオブジェクトには
reset_digestとreset_sent_atがあることを確認してみましょう。
また、それぞれの値はどのようになっていますか?
"reset_digest", "$2a$10$an7jBdlMlSmv2peunVbzZODIILfhU3EaXCvqYIG"
“reset_sent_at”, “2021-04-08 13:57:15.435675”
12.2 パスワード再設定のメール送信(11章やってれば飛ばしてOK)
12.2.1 パスワード再設定のメールとテンプレート
パスワード再設定のリンクをメール送信する
app/mailers/user_mailer.rb
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
演習
1:ブラウザから、送信メールのプレビューをしてみましょう。
「Date」の欄にはどんな情報が表示されているでしょうか?
http://3cfb3f407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset
↑にアクセスすると
Date:Wed, 08 Apr 2021 14:12:22 +0000
2:パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。
rails sで確認するだけ
3:コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。
オブジェクトを見つけたら、そのオブジェクトが
持つreset_digestとreset_sent_atの値を確認してみましょう。
reset_digest: "$2a$10$5aTNJOr.f /EGDHrzKmwMEOQ0.dgECVhOaKh8Y7e..." reset_sent_at: "2021-04-08 14:09:52">
演習
1:メイラーのテストだけを実行してみてください。このテストは
greenになっているでしょうか?
rails test:mailers
2:2つ目のCGI.escapeを削除すると、
テストがredになることを確認してみましょう。
ArgumentError: ArgumentError: wrong number of arguments (given 1, expected 2..3)
12.3.1 editアクションで再設定
パスワード再設定フォームを表示するビューが必要です。このビューはユーザーの編集フォームと似ていますが、
今回はパスワード入力フィールドと確認用フィールドだけで
十分です。
メールアドレスをキーとしてユーザーを検索するためには、
editアクションとupdateアクションの両方でメールアドレスが必要になるからです。
例のメールアドレス入りリンクのおかげで、editアクションで
メールアドレスを取り出すことは問題ありません。
しかしフォームを一度送信してしまうと、この情報は消えてしまいます。
隠しフィールドとしてページ内に保存する手法をとります。
これにより、フォームから送信したときに、
他の情報と一緒にメールアドレスが送信されるようになります。
app/views/password_resets/edit.html.erb
<% 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]に保存されますが、
後者ではparams[:user][:email] に保存されてしまうからです。
このフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義していきます。
params[:email]のメールアドレスに対応するユーザーをこの変数に保存します。
続いて、params[:id]の再設定用トークンと、抽象化したauthenticated?メソッドを使って、
このユーザーが正当なユーザーである
(ユーザーが存在する、有効化されている、認証済みである) ことを確認します。
editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行います。
パスワード再設定のeditアクション
app/controllers/password_resets_controller.rb
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
演習
1:手順に従って、Railsサーバーのログから送信メールを探し出し、
そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて下さい。
http://3cfb3f48c69407f8f01afa.vfs.cloud9.ap-northeast -1.amazonaws.com/rails/mailers/user_mailer/password_reset
2:先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?
成功する
12.3.2 パスワードを更新する
AccountActivationsコントローラのeditアクションでは、
ユーザーの有効化ステータスをfalseからtrueに変更しましたが、
今回の場合はフォームから新しいパスワードを送信するようになっています。
したがって、フォームからの送信に対応するupdateアクションが必要になります。
このupdateアクションでは、次の4つのケースを考慮する必要があります。
1:パスワード再設定の有効期限が切れていないか
2:無効なパスワードであれば失敗させる (失敗した理由も表示する)
3:新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4:新しいパスワードが正しければ、更新する
check_expirationメソッドでは、期限切れかどうかを確認する
インスタンスメソッド「password_reset_expired?」を使っています
# 期限切れかどうかを確認する def check_expiration if @user.password_reset_expired? flash[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end
errors.addを使ってエラーメッセージを追加します。
このように書くと、パスワードが空だった時に空の文字列に対するデフォルトの
メッセージを表示してくれるようになります。
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] # (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
user_params
メソッドを使ってpassword
とpassword_confirmation
属性を精査している点に注意してください。app/models/user.rb
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
演習
1:Railsサーバーのログから取得をブラウザで表示し、
passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?
「フォームに1つのエラーが含まれています。
パスワードの確認がパスワードと一致しません」
2:コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digest
の値を取得してみましょう。
次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう 。
パスワードの再設定は成功したら、再度password_digest
の値を取得し、先ほど取得した値と異なっていることを確認してみましょう。 新しい値はuser.reload
を通して取得する必要があります。
動作確認するだけ