第12章
第11章ではアカウント有効化の実装が完了した。
今回の章ではパスワードを忘れた時に再設定できる機能を追加する。
やることは第11章と似ている。
流れ
1.ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
2.該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応する再設定ダイジェストを生成する
3.再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
4.ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する(トークンを認証する)
5.認証に成功したら、パスワード変更用のフォームをユーザーに表示する
PasswordResetsリソース
まずは、PasswordResetsリソースのモデリングから始める。
前章と同じく新たなモデルは作らず、代わりに必要なデータをUserモデルに追加する。
PasswordResetsをリソースとして扱うため、RESTfulなURLを用意する。
今回は、ビューを描画するためのnew
アクションとedit
アクションが必要になる。
$ git checkout -b password-reset
トピックブランチを作成する。
PasswordResetsコントローラ
new
アクションとedit
アクションを含んだコントーラーの作成
$ rails generate controller PasswordResets new edit --no-test-framework
テストを生成しないオプションを指定している。
理由として、統合テストでカバーしていくから。
新しいパスワードを再設定するためのフォームとUserモデル内のパスワードを変更するためのフォームが必要。
従って、new
、create
、edit
、update
のルーティングを用意する。
Rails.application.routes.draw do
resources :password_resets, only: [:new, :create, :edit, :update]
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) |
RESTfulルーティング |
パスワード再設定画面へのリンクを追加
<%= link_to "(forgot password)", new_password_reset_path %>
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(url: login_path, scope: :session, local: true) 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>
新しいパスワードの設定
今までのように、トークン用の仮想的な属性とそれに対応するダイジェストを用意する。
トークン関連で学んだ事だが、トークンをハッシュ化せず(平文で)データベースに保存してしまうと、攻撃者がトークンを盗み出したときセキュリティ上問題がある。なので、ダイジェストを使おう。
また、再設定用リンクは期限を設ける。そのために、送信時刻を記録する必要がある。
以上を踏まえて、reset_digest
属性とreset_sent_at
属性をUserモデルに追加する。
上記のUsersテーブルのするためマイグレーションを実行
$ 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_with(url: password_resets_path, scope: :password_reset,
local: true) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.submit "Submit", class: "btn btn-primary" %>
<% end %>
</div>
</div>
演習
解答例
シンボルを使ってフォームを送信する事で、Railsが自動的に送信先に値を割り当てる。
create
アクションでパスワード再設定
Forgot passwordから送信後、メールアドレスをキーとしてユーザーをデータベースから探し、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する。
続いて、ルートURLにリダイレクトし、フラッシュメッセージを表示する。なお、送信が無効な場合は、new
ページを出力してfash.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モデルにもコードを追加していく。(create_activation_digest
メソッドと似ている)
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
# パスワード再設定の属性を設定する
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
end
この時点では、無効なメールアドレスを入力すると正常に動作する。
そして、正しいメールアドレスを送信した場合にも正常に動作させるには、パスワード再設定のメイラーメソッドを定義する必要がある。
パスワード再設定のメール送信
パスワード再設定に関するメールを送信する部分を作成する。
第11章でUserメイラーを生成した時、デフォルトのpassword_rest
メソッドもまとめて生成されている。
パスワード再設定のメールとテンプレート
UserメイラーにあるコードをUserモデルに移すリファクタリングを行う。
UserMailer.password_reset(self).deliver_now
このコードの実装のために必要なpassword_reset
メソッドを、Userメイラーに設定する。
def password_reset(user)
@user = user
mail to: user.email, subject: "Password reset"
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.
パスワード再設定テンプレート(HTML)
<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のメールプレビュー機能でプレビューを設定する。
class UserMailerPreview < ActionMailer::Preview
def password_reset
user = User.first
user.reset_token = User.new_token
UserMailer.password_reset(user)
end
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
パスワードを再設定する
PasswordResetsコントローラのedit
アクションの実装を進める。
また、統合テストでも行う。
edit
アクションで再設定
パスワード再設定の送信メールには、以下のようなリンクがある。
https://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=fu%40bar.com
このリンクを機能させるには、パスワード再設定のフォームを表示するビューが必要だ。
今回ちょっと面倒なのが、メールアドレスをキーとしてユーザーを検索するのに、edit
アクションとupdate
アクションの両方でメールアドレスが必要になる。
なので、最初はメールアドレス入りリンクのおかげでedit
アクションはOK。
しかし、フォームを一度送信すると、この情報は消える。
故に、この値をどこかに保存しよう!となる。
そこで今回使うのは、隠しフィールド。これでページ内にメールアドレスを保持し、フォームから送信した時に他の情報と一緒にメールアドレスが送信されるようになる。
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(model: @user, url: password_reset_path(params[:id]),
local: true) 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
メールアドレスを入力し送信した結果にあったリンクから開くと、、、
パスワード再設定用のページが出てきた。
演習
現時点では、パスワードを更新するボタンを押しても動作しない。
パスワードを更新する
フォームからの送信に対応するupdate
アクションが必要だ。
update
アクションの4つのケース
1.パスワード再設定の有効期限が切れていないか
2.無効なパスワードであれば失敗させる(失敗した理由も表示する)
3.新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった)
4.新しいパスワードが正しければ、更新する
1、2、4はこれまでの知識で対応可能。
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
上記のメソッドを呼び出しbeforeフィルターで保護したので、2と4のケースには対応できそう。
2は、無効なパスワードであれば失敗はOK
4は、更新が成功したらパスワードを再設定後、ログインさせればOK
問題としてパスワードが空文字だった時のケース。
以前Userモデルを作っていた時に、パスワードが空でも良いという実装をした。
従って、今回は明示的にキャッチする必要がある。
今回は、@user
オブジェクトにエラーメッセージを追加するという方法を取る。
やり方は、errors.add
を使ってエラーメッセージを追加する。
@user.errors.add(:password, :blank)
以上の結果から、1を除いたすべてのケースに対応したupdate
アクションが完成する。
class PasswordResetsController < ApplicationController
before_action :check_expiration, only: [:edit, :update] # (1)への対応
def update
if params[:user][:password].empty? # (3)への対応
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update(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
# トークンが期限切れかどうか確認する
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
@user.password_reset_expired?
を動作させるため、password_reset_expired?
メソッドをUserモデルに定義する。
2時間以上パスワードが再設定されなかった場合には、期限切れとする。
reset_sent_at < 2.hours.ago
不等号の「<
」は、「より少ない」と読むのではなく「早い」と考えると良い。
今回は2時間より早い=2時間以内捉えられる。
password_reset_expired?
メソッドの実装。
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
private
.
.
.
end
これで全体の実装はOK
パスワードの再設定をテストする
送信に成功した場合と失敗した場合の統合テストを作成する。
パスワード再設定のテストファイルを生成する。
$ 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'
assert_select 'input[name=?]', 'password_reset[email]'
# メールアドレスが無効
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
タグに正しい名前、tyepe="hidden"、メールアドレスがあるか確認している。
<input id="email" name="email" type="hidden" value="michael@example.com" />
テストはOK
本番環境でのメール送信
本番環境でのメール送信は第11章でやったので、あとはGitのトピックブランチをmasterにマージしておく。
$ rails test
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset
リモートレポジトリにプッシュし、Herokuにデプロイ
$ rails test
$ git push && git push heroku
$ heroku run rails db:migrate
最後に
第11章と似ている個所もあったが、難しい章になったと思う。
残りの章では、マイクロポスト機能とフォローなどのステータスフィード機能を実装していく。
has_many
やhas_many:through
が出てくる。