0
0

More than 3 years have passed since last update.

rails-tutorial第12章

Last updated at Posted at 2020-06-07

パスワードの再設定

必要なのは、ユーザーにemailを入力してもらうためのフォーム、アクション、こちらで受け取るためのアクション、

そして、urlをクリックしてもらったら、パスワードを再設定するためのeditアクションとフォーム、updateアクションが必要となる。

まずはPasswordResetsリソースを作る必要がある。

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

--no-test-frameworkは今現在の統合テストで十分足りている時にかく。
これにより、rails g controllerに付随するテストをスキップすることができる。

ルーティングを設定する。

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]
end

ログイン画面にリンクを設置する

パスワードを忘れてログインできない人のために、再設定用のリンクを設置する

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>

カラムを追加しよう

フォームを作る前に、Userモデルにパスワード再設定に必要なカラムを追加しよう

$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime

これはアカウント有効化と同じで、再設定用に作ったトークンのハッシュ値を入れておくカラムと、それが作られた時間を入れておくカラムを作る。

booleanの時はデフォルト値を入れていたが、今回はtrue,falseでもないのでデフォルト値の設定は不要。

newアクションのviewを作っていく

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>

form_forのところに注目しよう

<%= form_for(:password_reset, url: password_resets_path) do |f| %>

第一引数は:password_resetになっている。

リソース名をシンボルにして渡してあげることにより、デフォルトでpostリクエストが送りつけられる。(変えたければ、method :PATCHのようにすれば良い)password_resetsコントローラのcreateアクションにいくように設定される。

で、取り出すときも、params[:password_reset][:email]のようにすれば取り出すことができる。

パスワード再設定用の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を引っ張ってくる時に.downcaseメソッドを使っていることに注目。
これはユーザーがメールアドレスを大文字で入力してくることに配慮している。

次は if @userを見ていこう。

これはユーザーがタイポしてしまうケースに配慮している。
もしユーザーが見つからなければ、再度newアクションにrenderされる。

Userモデルに必要な情報を定義

今の状態だと、create_reste_digestメソッドや、send_password_reset_emailメソッド、それに、パスワードリセットに必要なトークンの平文(仮想的な属性)がないので、それを定義していく必要がある。

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 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

ここで注目すべきは

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

メールアドレスを入力し、ボタンを押し、再設定を申し込まれた時点でresent_sent_atカラムに値が格納されている。

前回のアカウント有効化の時は、ユーザーがリンクを踏んだ時に初めて情報が格納された。

なぜこうしているのか?

それは、パスワード再設定用のリンクに期限をつけたいからである。
(文字列を打ち込む時間が増えて攻撃される可能性があるから?)

Userメイラーのpassword_resetメソッドを実装していこう。

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

これ、なんでやるかというと、@userをメールのviewで使えるようにするため、
また、このメソッドを使うと、メールオブジェクトが返るから、それをdeliver_nowメソッドなどで送ることができるから。

メールの文面を変える

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>

メールのプレビューを確認しよう

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

これにより、メールオブジェクトが生成されるので、指定のurlでメールのプレビューを確認することができる。

パスワード再設定用メイラーメソッドのテストを追加する

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アクションと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>

form_forの引数に@userが送られているが、
これは前回もあったように@userが既存のオブジェクトなら、patchリクエストで送信されるということ。

また、urlを指定しないと、Userコントローラのupdateアクションに行ってしまうので、このように指定している。

今回は、攻撃に備えるために、editアクションでもupdateアクションでもauthenticate?メソッドを実行するらしい。

ただ、これにはupdateアクションでも@user.emailの情報がなければいけない。

これをどうするか?

一番簡単なのは、もう一度ユーザーにメールアドレスを打ち込んでもらうこと。

これをスマートにやるには、

hidden_field_tag

<%= hidden_field_tag :email, @user.email %>

hidden_field_tagはユーザーに入力させる必要がないものを扱う時に使う。

これはユーザーからは見えず、入力させることなく、@user.emailを次に次にと回していくことができる。

コントローラを実装しよう

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メソッドを見ていこう。

まず、emailからユーザーを引っ張ってきているが、万が一nilになってしまってることに備えて@userを条件に入れている。

また、DBに保存されてはいるけど、まだactivateされていないユーザーかもしれない。

そして、reset_digestとreset_tokenを比較して一致したら正しいユーザーですよーってこと。

また、1つ目のbefore_actionに関しては、editとupdateアクションがどちらもユーザーインスタンスを参照するので、その処理をまとめたもの。

パスワードの更新の4つの処理

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

以上4つの条件を踏まえて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

まずはこれを見ていこう

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

@user.errors.add(:password, :blank)
現在パスワードにはallow nilが設定されているので、空欄のまま更新できてしまう。
なので、もしパスワードが空なら、バリデーションが発動する前に問答無用でエラーを出そうねーってこと。

また、ますアサインメント脆弱性を補完するために、
ストロングパラメータを使う。

def user_params
  params.require(:user).permit(:password, :password_confirmation)
end

ただ、

if @user.password_reset_expired?

のインスタンスメソッドがまだ定義されていないので、定義する。

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

パスワード再設定のテストする

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

これは演習でいい。

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