#近況報告
エンジニア転職成功しました。YouTubeもはじめました。
著者略歴
著者:YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目
#第12章 パスワードの再設定 難易度 ★★★ 3時間
挫折しないRailsチュートリアルの進め方を先にお読みください↓↓
前章でアカウントの有効化の実装が完了し、ユーザーのメールアドレスが本人のものである確信が得られるようになった。
この章では、パスワードの再設定に取り組む。
この設定方法はアカウント有効化の方法と似通っていてる。
違う点としては、例えば
①パスワードの再設定する場合はビューを1つ変更する必要がある
②新しいフォームが新たに2つ(メールレイアウト用と新しいパスワードの送信用)必要になる。
コードを実際に書くまえに、パスワード再設定の想定手順をモックアップで確かめてみる。
まず、サンプルアプリケーションのログインフォームにforgot password
リンクを追加する。
このforgot password
リンクをクリックするとフォームが表示され、そこにメールアドレスを入力してメールを送信すると、メールが届き、そこにパスワード再設定用のリンクが記載されている。
この再設定用のリンクをクリックすると、ユーザーのパスワードを再設定してよいか確認を求めるフォームが表示される。
出典:図 12.1: 「forgot password」リンクのモックアップ
出典:図 12.2: 「forgot password」フォームのモックアップ
出典:図 12.3: パスワード再設定用フォームのモックアップ
パスワード再設定用のメイラーは既に生成されている筈なので、
本章では、ここで生成したメイラーにリソースとデータモデルを追加して、パスワードの再設定を実現していく。
アカウント有効化の際と似ていて、PasswordResetsリソースを生成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となる。
全体の流れは次の通り
①ユーザーがパスワードの再設定をリクエストすると、ユーザー宛に再設定用メールが届く。また、ユーザーが送信したメールアドレスをキーにして、DBからユーザー探し出す
②概要ユーザーがいた場合、再設定用のトークンとリセットダイジェストを生成する
③リセットダイジェストをDBに保存する。
④ユーザー宛のメールのURLに再設定用トークンを仕込み、ユーザーがクリックすると、メールアドレスをキーとしてユーザーを探し、DB内に保存しておいた再設定用ダイジェストと比較する。
⑤ユーザーが一致したら認証し、パスワード変更用のフォームをユーザーに表示する。
##12.1 PasswordResetsリソース
セッションやアカウント有効化の時と同様に、まずはPasswordResetsリソースのモデリングから始める。
Userモデルに必要なデータを追加していく形で進めていく。
PasswordResetsもリソースとして扱っていきたいので、まずは標準的なRESTfulなURLを用意する。
有効化のときはeditアクションだけを取り扱ったが、今回はパスワードを再設定するフォームが必要なので、
ビューを描画するためのnewアクションとeditアクションが必要になる。
また、それぞれのアクションに対応する作成用/更新用のアクションも最終的なRESTfulなルーティングには必要となる。
上の変更を加える前に、いつものようにトピックブランチを作る。
$ git checkout -b password-reset
Switched to a new branch 'password-reset'
###12.1.1 PasswordResetsコントローラ
準備が整ったところで、最初のステップとしてパスワード再設定用のコントローラを作ってみる。
先程のように今回のビューも扱うので、newアクションとeditアクションも一緒に生成している点に注意。
$ rails g controller PasswordResets new edit --no-test-framework
上記のコマンドでは、テストを生成していない
これはコントローラの単体テストを行うのではなく、11章で統合テストを作ったから。
また、今回の実装では新しいパスワードを再設定するためのフォームと、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'
post '/signup', to: 'users#create'
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
resources :users # usersリソースをRESTfullな構造にするためのコード。
resources :account_activations, only: [:edit] # editアクションのみaccount_activationsリソースを適用
resources :password_resets, only: [:new, :create, :edit, :update] # password再設定用のリソースを適用
end
password_resets
リソースは、以下のRESTfulのルーティングに従っている。
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) |
出典:表 12.1: リスト 12.1のPasswordResetsリソースで提供されるRESTfulルーティング
<% provide(:title, 'Log in') %>
<h1>ログイン</h1>
<div class="row"></div>
<div class="col-md-6 col-md-offset-3">
<!-- formの送信先を指定 -->
<%= form_for :session, url: login_path do |f| %> <!-- 送信先をurlが/login、Sessionsコントローラのcreateアクションに@userを送り、fブロックに代入-->
<!-- form作成-->
<%= 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>ログイン状態を保持する</span>
<% end %>
<!-- 送信ボタン-->
<%= f.submit "送信", class: "btn btn-primary" %>
<% end %>
<!-- 登録ページへのボタン -->
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
####演習
1:テストがパスすることを確認。
確認済み。
2:名前付きルートでは、_pathではなく_urlを使うがそれはなぜか?
トークンのURLは絶対パス(https://〜)を使用する必要があるため。
###12.1.2 新しいパスワードの設定
パスワード再設定のデータモデルも、アカウント有効化の場合と似通っている。
記憶トークンや有効化トークンでの実装パターンに倣って、パスワードの再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意していく。
もしトークンをハッシュ化せずにDBに保存してしまうとすると、攻撃者によってDBからトークンを読み出された時、セキリュティ上の問題が生じる。
例えば攻撃者がユーザーのメールアドレスにパスワード再設定のリクエストを送信し、このメールと盗んだトークンを組み合わせて攻撃者がパスワード再設定リンクを開けば、アカウントを奪い取ることができてしまう。
したがって、パスワードの再設定では必ずダイジェストを使うようにする。
セキュリティ上の注意点はもう1つ、それは再設定用のリンクはなるべく短時間で期限切れになるようにしなければならない。
そのために、再設定メールの送信時刻も記録する必要がある。
以上の背景に基づいて、reset_digest
属性とreset_sent_at
属性をUserモデルに追加する。
出典:図 12.5: パスワード再設定用のカラムを追加したUserモデル
次を実行して、マイグレーションを反映する。
$ rails g migration add_reset_to_users reset_digest:string reset_sent_at:datetime
$ rails db:migrate
新しいパスワード再設定の画面を作成するために、新しいセッションを作成するためのログインフォームを使う。
新しいパスワード再設定フォームは、多くの共通点があるが、重要な違いとして、
from_for
で扱うリソースとURLが異なっている点と、パスワード属性が省略されている点が挙げられる。
変更を反映した結果がこれ
<%= provide(:title, "Log in" ) %>
<h1>パスワード再発行</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アクションでパスワード再設定
パスワード再設定のフォームから送信を行なった後、メールアドレスをキーとしてユーザーをDBから見つけ、
パスワード再設定用トークンと送信時のタイムスタンプでDBの属性を更新する必要がある。
それに続いてルート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モデル内で書く
# パスワード再設定の属性を設定する
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
この時点では、無効なメールアドレスを入力した場合のみ正常に動作する。
正しいメールアドレスを送信した場合にもアプリケーションが正常に動作するためには、パスワード再設定のメイラーメソッドを定義する必要がある。
出典:図 12.7: 「forgot password」フォームに無効なメールアドレスを入力した場合
####演習
1:有効なメールアドレスをフォームから送信してみると、どんなエラーが表示されるか?
2:コンソールに移り、先ほどの演習課題で送信した結果、(エラーと表示されてはいるものの)該当するuserオブジェクトにはreset_digest
とreset_sent_at
があることを確認してみる。
$ rails c
$ user = User.find_by(email: "〇〇@gmail.com")
>> user.reset_digest
=> "$2a$10$xjW3SpB4fTdrQLn8ADGgAeqiFoEkYAJ60V2SPem5P1MvdF/sfIWF."
>> user.reset_sent_at
=> Sun, 27 Jan 2019 13:25:03 UTC +00:00
##12.2 パスワード再設定のメール送信
PasswordResetsコントローラで、createアクションがほぼ動作するところまで持っていった。
残すはパスワード再設定に関するメールを送信する部分。
すでにUserメイラーを生成した時に、デフォルトのpassword_resetメソッドもまとめて生成されるはず。
###12.2.1 パスワード再設定のメールとテンプレ
前章では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のメールプレビュー機能でパスワード再設定のメールをプレビューする。
def password_reset
user = User.first
user.reset_token = User.new_token
UserMailer.password_reset(user)
end
htmlバージョン
textバージョン
これで、正しいメールアドレスを送信することが可能となる。
実際にメールを送信すると、サーバーログでは以下のように表示される。
####演習
1:ブラウザから送信メールのプレビューをしてみると、Dateの欄にはどんな情報が表示されるか?
送信した日時の情報が表示される
2:パスワード再設定フォームから有効なメールアドレスを送信してみて、Railsサーバーのログから見て、生成された送信メールの内容を確認してみる。
----==_mimepart_5c4e7ea1e63f_13282cec988248a0
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<h1>Password reset</h1>
<p>To reset your password click the link below:</p>
<a href="https://eac437457e484fe491559aaa135f7f93.vfs.cloud9.us-east-2.amazonaws.com/password_resets/5yaWSzN-nDQmfgXWBukybw/edit?email=yuukitetsuyanet%40gmail.com">Reset password</a>
<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>
</body>
</html>
3:コンソールに移り、先ほどの演習問題でパスワード再設定をしたUserオブジェクトを探してみる。
オブジェクトを見つけたら、そのオブジェクトが持つreset_digest
とreset_sent_at
の値を確認してみる。
>> user.reset_digest
=> "$2a$10$ytvBnTzBj4tEkPhGNthyLeXRb0ohU.38BsPQr/5cabGIrpBg7JlI."
>> user.reset_sent_at
=> Mon, 28 Jan 2019 04:01:37 UTC +00:00
###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
これでテストはパスする。
$ rails t
####演習
1:メイラーのテストだけを実行してパスするか確認。
確認済み。
2:GCI.escapeを削除したらテストが失敗することを確認。
確認済み。
##12.3 パスワードを再設定する
無事に送信メールを生成できたので、次はPasswordResetsコントローラのeditアクションの実装を進めていく。
また、前章のときと同様に、統合テストを使ってうまく動作しているかのテストを行っていく。
###12.3.1 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
この書き方だと、再設定用リンクをクリックした際に、params[:email]
に保存される。
以前は、
f.hidden_field :email, @user.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 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
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?(:reset, params[:id])
このようなコードを使っている。
authenticated?(:remember, cookies[:remember_token])
以前使ったこのコードと似ている。
要は、:rememberの部分を:resetにして、受け取ったユーザーidの値をトークン化してダイジェスト化したものが、:resetの値をダイジェスト化したものと一致していれば、trueを返すという仕組み。
さらに
authenticated?(:activation, params[:id])
これはアカウント有効化のコントローラで使ったメソッドである。
つまり、以上のコードが11.1で使った認証メソッドであり、今回追加したコードで全て実装が完了したことになる。
これで、送られてきたメールのURLをクリックすると、パスワード再設定のフォームが出力されるようになった。
####演習
1:Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてみる。
そのリンクをブラウザから表示してみて、Reset Passwordの画面が表示されるか確認。
確認済み
2:実際に新しいパスワードを送信するとどうなるか?
###12.3.2 パスワードを更新する
AccountActivationsコントローラのeditアクションでは、ユーザーの有効化ステータスをfalse
からtrue
に変更下が、今回の場合はフォームから新しいパスワードを送信するようになっている。
したがって、フォームからの送信に対応するupdate
アクションが必要になる。
このupdateアクションでは
①パスワード再設定の有効期限が切れていないか
②無効なパスワードであれば失敗させる(失敗した理由も表示する)
③新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった)
④新しいパスワードが正しければ、更新する
このような順序で確認し、処理を行う。
まず、1についてはeditとupdateアクションに次のようなメソッドとbeforeフィルターを用意することで対応できる。
before_action :check_expiration, only: [:edit, :update]
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
password_reset_expired?
は期限切れかどうかを確認するインスタンスメソッドで、のちに定義する。
これから②の問題を解消していく。
②では更新が失敗した時にeditビューが再描画され、edit.html.erb
のパーシャルにエラーメッセージを表示させるようにすれば解決できる。
次に④については、更新が成功した時にパスワードを再設定し、あとはログインに成功した時と同様の処理を進めていけば問題ない。
③についての処理が難しい(パスワードがから文字だった場合の処理)
その理由として、以前Userモデルを作っていた時に、パスワードが空でも良いという実装をしたから。
したがって、このケースについては明示的にキャッチするコードを追加する必要がある。
そのために、@userオブジェクトにエラーメッセージを追加する。
@user.errors.add(:password, :blank)
このように書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれるようになる。
以上の結果をまとめて、①の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]
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?
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(user_params)
log_in @user
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit'
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モデルに移譲する前提)ので、次のようにコードを書いていた。
@user.password_reset_expired?
上記のコードを動作させるために、password_reset_expired?
メソッドをUserモデルで定義する。
このメソッドでは、パスワード再設定の期限を設定して、2時間以上パスワードが再設定されなかった場合は期限切れとする処理を行う。
これをRubyで表現すると
reset_sent_at < 2.hours.ago
上の<記号を「〜より少ない」と読んでしまうと、パスワード再設定メール送信時から経過した時間が、2時間より少ない場合となってしまい、困惑してしまう点に注意。
ここで行っている処理は、「少ない」ではなく「早い」と捉えると理解しやすい。
つまり、<記号を**〜より早い時刻**と読む。
こうすることで、パスワード再設定メールの送信時刻が、現在時刻より2時間以上前の場合となり、期待通りの条件となる。
実際のメソッドの処理を書いていく。
class User < ApplicationRecord
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
rent_sent_at < 2.hours.ago
end
private
end
リストコードを使うと、update
アクションが動作するようになる。
送信が無効だった場合と有効だった場合の画面がこちら。
####演習
1:Railsサーバーのログから再設定リンクを取得し、confirmationの文字列をわざと間違えて送信するとどんなエラーメッセージが出るか?
確認済み
2:コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つける。
そのオブジェクトのpassword_digestの値を、送信失敗時と送信成功時で見比べてみる。
取得した値が異なっていることを確認する。
$ user = User.find_by(email: "yuukitetsuyanet@gmail.com")
$ user.password_digest
=> "$2a$10$aKbLCrMfGEWYXAQ5NSEvxuU5qI/sOFyiehIgFwNMWMifDcRX7bxFO"
$ user.reload
$ user.password_digest
=> "$2a$10$RtF8nSd22GLENhTmlBTkPeaqyPEs9E8v7f3UeR6UecG4ydjxCuNOO"
###12.3.3 パスワードの再設定をテストする
この項では、送信に成功した場合と失敗した場合の統合テストを作成する。
まずはパスワード再設定のテストファイルを生成する。
$ rails g integration_test password_resets
パスワード再設定をテストする手順は、アカウント有効化のテストと多くの共通点があるが、テストの冒頭部分には次のような違いがある。
最初にforgot password
フォームを表示して無効なメールアドレスを送信し、
次はそのフォームで有効なメールアドレスを送信する。
後者ではパスワード再設定用トークンが作成され、再設定用メールが送信される。
続いて、メールのリンクを開いて無効な情報を送信し、次にそのリンクから有効な情報を送信して、それぞれが期待通りに動作することを確認する。
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:michael)
end
test "should do something" 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
ここで、inputタグを使っている点に注目。
assert_select "input[name=email][type=hidden][value=?]", user.email
上記のコードは、inputタグに
- 正しい名前
type="hidden"
- メールアドレス
があるかどうかを確認している。
<input id="email" name="email" type="hidden" value="michael@example.com" />
上記のコードを使うとテストはパスするはず。
$ rails t
####演習
1:user.rbでのcreate_reset_digest
メソッドはupdate_attribute
を2回呼び出しているが、これは各行で一回ずつDBをへ問い合わせしていることになる。
以前と同様、update_columns
呼び出しにまとめて見る。
また、変更後にテストがパスすることを確認する。
# パスワード再設定の属性を設定する
def create_reset_digest
self.reset_token = User.new_token
update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now )
end
11 tests, 15 assertions, 0 failures, 0 errors, 0 skips
2:期限切れのパスワード再設定で発生する分岐を統合テストで網羅してみる。
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:michael)
end
test "should do something" 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
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/i, response.body
end
end
3:2時間たったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方。
しかし、もっと良くする方法がまだあり、例えば公共のコンピュータでパスワード再設定が行われた場合は、ログアウトして離席したとしても2時間以内であれば履歴からパスワード再設定フォームを表示させてパスワードを更新することが可能。
この問題を解決するため、パスワードの再設定に成功したらダイジェストをnilになるように変更する。
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
4:password_resets_test.rb
に3でやった部分のテストを書いてみる。
# 有効なパスワードとパスワード確認
patch password_reset_path(user.reset_token),
params: { email: user.email,
user: { password: "foobaz",
password_confirmation: "foobaz" } }
assert_nil user.reload.reset_digest
assert is_logged_in?
assert_not flash.empty?
assert_redirected_to user
##12.4 本番環境でのメール送信
これでパスワード再設定の実装も終わったので、あとは前章と同様に、development環境だけでなくproduction環境でも動くようにするだけ。
セットアップの手順はアカウント有効化と全く同じ。
SendGridを使ってメールを送信する。
設定は11章で行なっているので、あとはGitのトピックブランチをmasterにマージして、Herokuにデプロイする。
$ rails t
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset
$ rails t
$ git push
$ git push heroku
$ heroku run rails db:migrate
実際にproduction環境でパスワード再設定のメールを送り、再設定してみる。
#SendGridがスパム判定で使えないのでHeroku logsから
2019-01-28T16:19:38.626415+00:00 app[web.1]: <!DOCTYPE html>
2019-01-28T16:19:38.626417+00:00 app[web.1]: <html>
2019-01-28T16:19:38.626419+00:00 app[web.1]: <head>
2019-01-28T16:19:38.626420+00:00 app[web.1]: <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
2019-01-28T16:19:38.626422+00:00 app[web.1]: <style>
2019-01-28T16:19:38.626427+00:00 app[web.1]: /* Email styles need to be inline */
2019-01-28T16:19:38.626429+00:00 app[web.1]: </style>
2019-01-28T16:19:38.626431+00:00 app[web.1]: </head>
2019-01-28T16:19:38.626432+00:00 app[web.1]:
2019-01-28T16:19:38.626434+00:00 app[web.1]: <body>
2019-01-28T16:19:38.626435+00:00 app[web.1]: <h1>Password reset</h1>
2019-01-28T16:19:38.626437+00:00 app[web.1]:
2019-01-28T16:19:38.626438+00:00 app[web.1]: <p>To reset your password click the link below:</p>
2019-01-28T16:19:38.626440+00:00 app[web.1]:
2019-01-28T16:19:38.626442+00:00 app[web.1]: <a href="https://yuuki-heroku-sample.herokuapp.com/password_resets/9ZcCCHWb8TTuRDjw3Nu-JQ/edit?email=yuukitetsuyanet%40gmail.com">Reset password</a>
2019-01-28T16:19:38.626444+00:00 app[web.1]:
2019-01-28T16:19:38.626445+00:00 app[web.1]: <p>This link will expire in two hours.</p>
2019-01-28T16:19:38.626447+00:00 app[web.1]:
2019-01-28T16:19:38.626449+00:00 app[web.1]: <p>
2019-01-28T16:19:38.626450+00:00 app[web.1]: If you did not request your password to be reset, please ignore this email and
2019-01-28T16:19:38.626452+00:00 app[web.1]: your password will stay as it is.
2019-01-28T16:19:38.626453+00:00 app[web.1]: </p>
2019-01-28T16:19:38.626455+00:00 app[web.1]: </body>
2019-01-28T16:19:38.626456+00:00 app[web.1]: </html>
2019-01-28T16:19:38.626458+00:00 app[web.1]:
####演習
1:ユーザー登録を試して、登録したメールアドレス宛にメールは届くか確認。
確認済み。
2:メールを受信できたら、実際にメールをクリックしてアカウント有効化して見る。
また、有効化のログを調べてみる。
2019-01-28T16:25:28.530104+00:00 app[web.1]: D, [2019-01-28T16:25:28.529979 #8] DEBUG -- : [a0a47171-d01a-4643-b59c-de3ce17467b5] SQL (3.5ms) UPDATE "users" SET "activated" = 't', "activated_at" = '2019-01-28 16:25:28.520822' WHERE "users"."id" = $1 [["id", 4]]
SQLで有効化されているのがわかる。
3:パスワードの再設定を試してパスワードの再設定が正しくできるか?
確認済み。
単語集
- mail.from
送信者をメールアドレスでなく、名前で表示する時に使う
例
mail(from: '"表示名" <noreply@yoursite.com>' )
- response.body
全てのHTML本文を返すメソッド。