パスワードの再設定
アカウント有効化の際と似ていて、PasswordResetsリソースを作成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となる。
全体の流れは次のとおり。
- ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
- 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する
- 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
- ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)
- 認証に成功したら、パスワード変更用のフォームをユーザーに表示する
PasswordResetsリソース
PasswordResetsコントローラ
最初のステップとしてパスワード再設定用のコントローラを作ってみる。
今回はビューも扱うので、newアクションとeditアクションも一緒に生成している。
rails generate controller PasswordResets new edit --no-test-framework
上のコマンドでは、テストを生成しないというオプションを指定しているが、これはコントローラの単体テストをする代わりに、統合テストでカバーしていくから。
また今回の実装では、新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、new、create、edit、updateのルーティングも用意する。
この変更は、前回と同様にルーティングファイルのresources行で行う。
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] # PasswordResetsリソースの追加
end
HTTPリクエスト | URL | Action | 名前付きルート |
---|---|---|---|
GET | /password_resets/new | new | new_password_reset_path |
POST | /password_resets | create | password_resets_path |
GET | /password_resets//edit | edit | edit_password_reset_url(token) |
PATCH | /password_resets/ | update | password_reset_url(token) |
<% 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つある。
それは再設定用のリンクはなるべく短時間(数時間以内)で期限切れになるようにしなければならない。
そのために、再設定メールの送信時刻も記録する必要がある。
rails generate migration add_reset_to_users reset_digest:string \
> reset_sent_at:datetime
rails db:migrate
新しいパスワード再設定の画面を作成するために、ログインフォームを参考に使う。
新しいパスワード再設定フォームはログインフォームと多くの共通点があるが、重要な違いとして、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>
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
Userモデル内のコードは、before_createコールバック内で使われる。
create_activation_digestメソッドと似ている。
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_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)
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メイラーにあるコードをUserモデルに移すリファクタリングを行った。
同様のリファクタリング作業を、パスワード再設定に対しても行っていく。
UserMailer.password_reset(self).deliver_now
上のコードの実装に必要なメソッドは前回実装したアカウント有効化用メイラーメソッドとほぼ同じ。
最初にUserメイラーにpassword_resetメソッドを作成し、続いて、テキストメールのテンプレートと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>
アカウント有効化メールの場合と同様、Railsのメールプレビュー機能でパスワード再設定のメールをプレビューする。
そのためのコードはaccount_activationと基本的にまったく同じ。
# 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
上のコードで、HTMLメールとテキストメールをそれぞれプレビューできるようになる。
送信メールのテスト
アカウント有効化のテストと同様に、メイラーメソッドのテストを書いていく。
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
パスワードを再設定する
editアクションで再設定
パスワード再設定の送信メールには、次のようなリンクが含まれているはず。
https://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=fu%40bar.com
このリンクを機能させるためには、パスワード再設定フォームを表示するビューが必要。
このビューはユーザーの編集フォームと似ているが、今回はパスワード入力フィールドと確認用フィールドだけで十分。
ただし、今回の作業は少しだけ面倒な点がある。
というのも、メールアドレスをキーとしてユーザーを検索するためには、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]に保存されるが、後者ではparams[:user][:email] に保存されてしまうから。
今度は、このフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義していく。
アカウント有効化の場合と同様、params[:email]のメールアドレスに対応するユーザーをこの変数に保存する。
続いて、params[:id]の再設定用トークンと、抽象化したauthenticated?メソッドを使って、このユーザーが正当なユーザーである(ユーザーが存在する、有効化されている、認証済みである)ことを確認する。
editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行う。
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
# 上(valid_userメソッド)で使ったコード
authenticated?(:reset, params[:id])
上のコードを下のコードと比べてみましょう。
# current_userメソッドで使ったコード
authenticated?(:remember, cookies[:remember_token])
このコードは、リスト 11.28で使われたコードです。さらにもう1つ、
# editアクションで使ったコード
authenticated?(:activation, params[:id])
以上のコードが表で示した認証メソッドであり、また、今回追加したコードですべて実装が完了したことになる。
パスワードを更新する
AccountActivationsコントローラのeditアクションでは、ユーザーの有効化ステータスをfalseからtrueに変更したが、今回の場合はフォームから新しいパスワードを送信するようになっている。
したがって、フォームからの送信に対応するupdateアクションが必要になる。
このupdateアクションでは、次の4つのケースを考慮する必要がある。
- パスワード再設定の有効期限が切れていないか
- 無効なパスワードであれば失敗させる(失敗した理由も表示する)
- 新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった)
- 新しいパスワードが正しければ、更新する
(1) と (2) と (4) はこれまでの知識で対応できそうだが、(3) はどのように対応すれば良いのかあまり明確ではないと思われる。
(1) については、editとupdateアクションに次のようなメソッドとbeforeフィルターを用意することで対応できると思われる。
before_action :check_expiration, only: [:edit, :update] # (1) への対応案
このcheck_expirationメソッドは、有効期限をチェックするPrivateメソッドとして定義する。
# 期限切れかどうかを確認する
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
上のcheck_expirationメソッドでは、期限切れかどうかを確認するインスタンスメソッド「password_reset_expired?」を使っている。
この新しいメソッドについては後ほど説明することにする。
今は上記の4つのケースについて先に考えていく。
まず、上のbeforeフィルターで保護したupdateアクションを使うことで、(2) と (4) のケースに対応することができると思われる。
例えば (2) については、更新が失敗したときにeditのビューが再描画され、パーシャルにエラーメッセージが表示されるようにすれば解決できる。
(4) については、更新が成功したときにパスワードを再設定し、あとはログインに成功したとき と同様の処理を進めていけば問題ないと思われる。
今回の小難しい問題点は、パスワードが空文字だった場合の処理。
というのも、以前Userモデルを作っていたときに、パスワードが空でも良い(allow_nil)という実装をしたから。
したがって、このケースについては明示的にキャッチするコードを追加する必要がある。
これが、先ほど示した考慮すべき点の (3) に当たる。
これを解決する方法として、今回は@userオブジェクトにエラーメッセージを追加する方法をとってみる。
具体的には、次のようにerrors.addを使ってエラーメッセージを追加する。
@user.errors.add(:password, :blank)
このように書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれるようになる。
以上の結果をまとめると、(1) のpassword_reset_expired?の実装を除き、すべてのケースに対応した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
(上のコードでは、以前実装したときと同様に、user_paramsメソッドを使ってpasswordとpassword_confirmation属性を精査している)
あとは、残しておいた上のコードの実装だけ。
今回は先回りして、始めからUserモデルに移譲する前提で次のようにコードを書いていた。
@user.password_reset_expired?
上のコードを動作させるために、password_reset_expired?メソッドをUserモデルで定義していく。
パスワード再設定のメールとテンプレートを参考に、このメソッドではパスワード再設定の期限を設定して、2時間以上パスワードが再設定されなかった場合は期限切れとする処理を行う。
これをRubyで表現すると次のようになる。
reset_sent_at < 2.hours.ago
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
パスワードの再設定をテストする
この項では、パスワード再設定のupdateアクションの2つの分岐、つまり送信に成功した場合と失敗した場合の統合テストを作成する。
rails generate integration_test password_resets
パスワード再設定をテストする手順は、アカウント有効化のテストと多くの共通点があるが、テストの冒頭部分には次のような違いがある。
最初に「forgot password」フォームを表示して無効なメールアドレスを送信し、次はそのフォームで有効なメールアドレスを送信する。
後者ではパスワード再設定用トークンが作成され、再設定用メールが送信される。
続いて、メールのリンクを開いて無効な情報を送信し、次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを確認する。
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
assert_select "input[name=email][type=hidden][value=?]", user.email
上のコードは、inputタグに正しい名前、type="hidden"、メールアドレスがあるかどうかを確認する。
<input id="email" name="email" type="hidden" value="michael@example.com" />
本番環境でのメール送信(再掲)
これでパスワード再設定の実装も終わった。
あとは前回と同様に、development環境だけでなくproduction環境でも動くようにするだけ。
セットアップの手順はアカウント有効化と全く同じなため、省略する。
rails test
git add -A
git commit -m "Add password reset"
git checkout master
git merge password-reset
rails test
git push
git push heroku
heroku run rails db:migrate