LoginSignup
0

More than 5 years have passed since last update.

Ruby on Rails Tutorial 12章 メモ

Posted at

パスワードの再設定

アカウント有効化の際と似ていて、PasswordResetsリソースを作成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となる。
全体の流れは次のとおり。

  1. ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける
  2. 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する
  3. 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
  4. ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)
  5. 認証に成功したら、パスワード変更用のフォームをユーザーに表示する

PasswordResetsリソース

PasswordResetsコントローラ

最初のステップとしてパスワード再設定用のコントローラを作ってみる。
今回はビューも扱うので、newアクションとeditアクションも一緒に生成している。

rails generate controller PasswordResets new edit --no-test-framework

上のコマンドでは、テストを生成しないというオプションを指定しているが、これはコントローラの単体テストをする代わりに、統合テストでカバーしていくから。

また今回の実装では、新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、new、create、edit、updateのルーティングも用意する。
この変更は、前回と同様にルーティングファイルのresources行で行う。

config/routes.rb
 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)
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つある。
それは再設定用のリンクはなるべく短時間(数時間以内)で期限切れになるようにしなければならない。
そのために、再設定メールの送信時刻も記録する必要がある。

rails generate migration add_reset_to_users reset_digest:string \
> reset_sent_at:datetime
rails db:migrate

新しいパスワード再設定の画面を作成するために、ログインフォームを参考に使う。
新しいパスワード再設定フォームはログインフォームと多くの共通点があるが、重要な違いとして、form_forで扱うリソースとURLが異なっている点と、パスワード属性が省略されている点が挙げられる。

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>

createアクションでパスワード再設定

フォームから送信を行なった後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要がある。
それに続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する。
送信が無効の場合は、ログインと同様にnewページを出力してflash.nowメッセージを表示する。

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メソッドと似ている。

app/models/user.rb
 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メールのテンプレートをそれぞれ定義する。

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
app/views/user_mailer/password_reset.text.erb
 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.
app/views/user_mailer/password_reset.html.erb
 <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と基本的にまったく同じ。

test/mailers/previews/user_mailer_preview.rb
 # 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メールとテキストメールをそれぞれプレビューできるようになる。

送信メールのテスト

アカウント有効化のテストと同様に、メイラーメソッドのテストを書いていく。

test/mailers/user_mailer_test.rb
 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アクションでメールアドレスを取り出すことは問題ない。
しかしフォームを一度送信してしまうと、この情報は消えてしまう。
今回はこのメールアドレスを保持するため、隠しフィールドとしてページ内に保存する手法をとる。
これにより、フォームから送信したときに、他の情報と一緒にメールアドレスが送信されるようになる。

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の検索とバリデーションを行う。

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
# 上(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つのケースを考慮する必要がある。

  1. パスワード再設定の有効期限が切れていないか
  2. 無効なパスワードであれば失敗させる(失敗した理由も表示する)
  3. 新しいパスワードが空文字列になっていないか(ユーザー情報の編集ではOKだった)
  4. 新しいパスワードが正しければ、更新する

(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アクションが完成する。

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属性を精査している)

あとは、残しておいた上のコードの実装だけ。
今回は先回りして、始めからUserモデルに移譲する前提で次のようにコードを書いていた。

@user.password_reset_expired?

上のコードを動作させるために、password_reset_expired?メソッドをUserモデルで定義していく。
パスワード再設定のメールとテンプレートを参考に、このメソッドではパスワード再設定の期限を設定して、2時間以上パスワードが再設定されなかった場合は期限切れとする処理を行う。
これをRubyで表現すると次のようになる。

reset_sent_at < 2.hours.ago
app/models/user.rb
 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」フォームを表示して無効なメールアドレスを送信し、次はそのフォームで有効なメールアドレスを送信する。
後者ではパスワード再設定用トークンが作成され、再設定用メールが送信される。
続いて、メールのリンクを開いて無効な情報を送信し、次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを確認する。

test/integration/password_resets_test.rb
 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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0