0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Ruby on Rails チュートリアル第12章をやってみて

Posted at

#パスワードの再設定
■第12章
本章では、ここで生成したメイラーにリソースとデータモデルを追加して、パスワードの再設定を実現していく。

##12.1 PasswordResetsリソース
必要なデータをUserモデルに追加していく。11章ではeditアクションのみだったが、今回はフォームが必要なのでnewアクションとeditアクションが必要。

###12.1.1 PasswordResetsコントローラ
パスワード設定用のコントローラを作成する。今回はビューも扱うため、newアクションとeditアクションも一緒に生成する。

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

新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、newcreateeditupdateのルーティングも用意する。

以下のコードを追加。

routes.rb
resources :password_resets,     only: [:new, :create, :edit, :update]

パスワード再設定画面へのリンクを追加して、テストもGREENに。

###12.1.2 新しいパスワードの設定
トークン用の仮想的な属性とそれに対応するダイジェストを用意する。
また、トークンを平文でデータベースに保存してしまうとセキュリティ上の問題があるため、必ずダイジェストを利用する。

再設定用のリンクはなるべく短時間で期限切れになるようにする。

reset_digest属性とreset_sent_at属性をUserモデルに追加。

$ rails generate migration add_reset_to_users reset_digest:string \
> reset_sent_at:datetime

その後はマイグレーション。

ログインフォームのコードを改変して、下記を作成。form_forで扱うリソースとURLが異なっている点と、パスワード属性が省略されている点が違う。

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>

無事、「forgot password」フォームが完成。
スクリーンショット 2022-02-19 18.06.30.png

###12.1.3 createアクションでパスワード再設定
フォームから送信を行なった後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要がある。

それに続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する。

送信が無効の場合、ログインと同様にnewページを出力してflash.nowメッセージを表示する。

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

パスワード再設定の属性を設定する。トークンを生成し、それをダイジェストに変換して設定。時間も設定する。

ここまでで、無効なメールアドレスを入力したときの動作が完成。
有効なメールアドレスを入力をした場合の動作を次からやる。

##12.2 パスワード再設定のメール送信
パスワード再設定に関するメールを送信する部分を作っていく。

Userメイラーを生成したときに、デフォルトのpassword_resetメソッドもまとめて生成されているはず。

###12.2.1 パスワード再設定のメールとテンプレート
パスワード再設定のリンクをメール送信する。

password_resetメソッドを作成。

user_mailer.rb
def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end

テキストとHTMLも改変する。
プレビューも完成させる。

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

プレビューできました。
スクリーンショット 2022-02-19 18.50.05.png

###12.2.2 送信メールのテスト
メイラーメソッドのテストを書いていく。

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

テストもGREENに。

##12.3 パスワードを再設定する
editアクションの実装を進める。統合テストも書いていく。

###12.3.1 editアクションで再設定
メールアドレスを保持するため、隠しフィールドとしてページ内に保存する手法をとる。

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>

editupdateでユーザー情報を取得して、正しいユーザーでなければルートページに飛ばす。

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

###12.3.2 パスワードを更新する
今回はフォームから新しいパスワードを送信するようになっています。したがって、フォームからの送信に対応するupdateアクションが必要になる。

updateアクションでは以下の4つを考慮する
①パスワード再設定の有効期限が切れていないか

②無効なパスワードであれば失敗させる

③新しいパスワードが空文字列になっていないか

④新しいパスワードが正しければ更新する

①、②、④はbefore_actionで対応。

③がちょっと小難しい。今回は@userオブジェクトにエラーメッセージを追加する方法をとってみる。

@user.errors.add(:password, :blank)

こう書くと、パスワードが空だった時に空の文字列に対するデフォルトのメッセージを表示してくれる。

パスワード再設定のupdateアクション。

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
reset_sent_at < 2.hours.ago

これで「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前 (早い)」 という意味になる。

###12.3.3 パスワードの再設定をテストする
送信に成功した場合と失敗した場合の統合テストを作成する。

テストファイル作成。

$ rails generate integration_test password_resets
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

これでテストもGREENに。

##感想
12章はすんなり終えることができました。ただ、パスワードの再設定の実装だけでも、ゼロからやるとなると苦労しそうだなーという印象でした。

0
0
0

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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?