日別アーカイブ: 2021年12月1日

Rails-tutorialのまとめ(第12章 パスワードの再設定 主に演習)

その11から続く

第12章パスワードの再設定

全体の流れは次のとおりです。

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

12.1 PasswordResetsリソース
12.1.1 PasswordResetsコントローラ

今回はビューも扱うので、newアクションとeditアクションも一緒に生成している点に注意してください。

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

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

パスワード再設定用リソースを追加する
config/routes.rb

resources :password_resets, only: [:new, :create, :edit, :update]

HTTPリクエスト URL                        Action      名前付きルート
GET        /password_resets/new          new     new_password_reset_path
POST       /password_resets              create  password_resets_path
GET        /password_resets/<token>/edit edit    edit_password_reset_url(token)
PATCH      /password_resets/<token>      update  password_reset_url(token)

PasswordResetsリソースで提供されるRESTfulルーティング

パスワード再設定画面へのリンクを追加する
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:この時点で、テストスイートが greenになっていることを確認してみましょう

rails test

2:名前付きルートでは、_pathではなく_urlを使うがそれはなぜか?

トークンのURLは絶対パス(https://〜)を使用する必要があるからだよ

12.1.2 新しいパスワードの設定

パスワードの再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意していきます。
もしトークンをハッシュ化せずに (つまり平文で)データベースに
保存してしまうとすると、
攻撃者によってデータベースからトークンを読み出されたとき、
セキュリティ上の問題が生じます。

パスワードの再設定では必ずダイジェストを使うようにしてください。
セキュリティ上の注意点はもう1つあります。
それは再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。

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

新しいパスワード再設定画面ビュー
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>

演習

1:form_forメソッドでは、なぜ@password_resetではなく:password_resetを使っているのでしょうか?考えてみてください。

シンボルを使ってフォームをするとRailsが自動で送信先に値を割り当ててくれるから。

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

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

パスワード再設定用のcreateアクション
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メソッドと似ています

Userモデルにパスワード再設定用メソッドを追加する
app/models/user.rb

class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  #中略

  # パスワード再設定の属性を設定する
  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
  #後略
end

演習

1:試しに有効なメールアドレスをフォームから送信してみましょう
どんなエラーメッセージが表示されたでしょうか?

ArgumentError in PasswordResetsController#create
wrong number of arguments (given 1, expected 0)

2:コンソールに移り、先ほどの演習課題で送信した結果、
(エラーと表示されてはいるものの) 該当するuserオブジェクトには
reset_digestとreset_sent_atがあることを確認してみましょう。
また、それぞれの値はどのようになっていますか?

"reset_digest",
"$2a$10$an7jBdlMlSmv2peunVbzZODIILfhU3EaXCvqYIG"

“reset_sent_at”, “2021-04-08 13:57:15.435675”

12.2 パスワード再設定のメール送信(11章やってれば飛ばしてOK)

12.2.1 パスワード再設定のメールとテンプレート

パスワード再設定のリンクをメール送信する
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

演習

1:ブラウザから、送信メールのプレビューをしてみましょう。
「Date」の欄にはどんな情報が表示されているでしょうか?

http://3cfb3f407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset

↑にアクセスすると
Date:Wed, 08 Apr 2021 14:12:22 +0000

2:パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。

rails sで確認するだけ

3:コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。
オブジェクトを見つけたら、そのオブジェクトが
持つreset_digestとreset_sent_atの値を確認してみましょう。

reset_digest: "$2a$10$5aTNJOr.f
/EGDHrzKmwMEOQ0.dgECVhOaKh8Y7e..."

reset_sent_at: "2021-04-08 14:09:52">

演習

1:メイラーのテストだけを実行してみてください。このテストは
greenになっているでしょうか?

rails test:mailers

2:2つ目のCGI.escapeを削除すると、
テストがredになることを確認してみましょう。

ArgumentError: ArgumentError: wrong number of arguments
(given 1, expected 2..3)

12.3.1 editアクションで再設定

パスワード再設定フォームを表示するビューが必要です。このビューはユーザーの編集フォームと似ていますが、
今回はパスワード入力フィールドと確認用フィールドだけで
十分です。

メールアドレスをキーとしてユーザーを検索するためには、
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の検索とバリデーションを行います。

パスワード再設定のeditアクション
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

演習

1:手順に従って、Railsサーバーのログから送信メールを探し出し、
そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて下さい。

http://3cfb3f48c69407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset

2:先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?

成功する

12.3.2 パスワードを更新する

AccountActivationsコントローラのeditアクションでは、
ユーザーの有効化ステータスをfalseからtrueに変更しましたが、
今回の場合はフォームから新しいパスワードを送信するようになっています。
したがって、フォームからの送信に対応するupdateアクションが必要になります。
このupdateアクションでは、次の4つのケースを考慮する必要があります。

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

check_expirationメソッドでは、期限切れかどうかを確認する
インスタンスメソッド「password_reset_expired?」を使っています

# 期限切れかどうかを確認する
def check_expiration
  if @user.password_reset_expired?
    flash[:danger] = "Password reset has expired."
    redirect_to new_password_reset_url
  end
end

errors.addを使ってエラーメッセージを追加します。

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

パスワード再設定の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メソッドを使ってpasswordpassword_confirmation属性を精査している点に注意してください。
Userモデルにパスワード再設定用メソッドを追加するapp/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

演習

1:Railsサーバーのログから取得をブラウザで表示し、
passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?

「フォームに1つのエラーが含まれています。
パスワードの確認がパスワードと一致しません」

2:コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。
次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう 。
パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。 新しい値はuser.reloadを通して取得する必要があります。

動作確認するだけ

12.3に続く