rails-tutorial第12章

#パスワードの再設定

必要なのは、ユーザーに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
```

これは演習でいい。