Railsチュートリアル(第6版)　第12章　パスワードの再設定

#第12章
第11章ではアカウント有効化の実装が完了した。

今回の章ではパスワードを忘れた時に再設定できる機能を追加する。
やることは第11章と似ている。

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

##PasswordResetsリソース
まずは、PasswordResetsリソースのモデリングから始める。
前章と同じく新たなモデルは作らず、代わりに必要なデータをUserモデルに追加する。

PasswordResetsをリソースとして扱うため、RESTfulなURLを用意する。
今回は、ビューを描画するための`new`アクションと`edit`アクションが必要になる。

`$ git checkout -b password-reset`

トピックブランチを作成する。

###PasswordResetsコントローラ
`new`アクションと`edit`アクションを含んだコントーラーの作成

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

テストを生成しないオプションを指定している。
理由として、統合テストでカバーしていくから。

新しいパスワードを再設定するためのフォームとUserモデル内のパスワードを変更するためのフォームが必要。
従って、`new`、`create`、`edit`、`update`のルーティングを用意する。

```rb:config/routes.rb
Rails.application.routes.draw do
 resources :password_resets,     only: [:new, :create, :edit, :update]
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)|
RESTfulルーティング

パスワード再設定画面へのリンクを追加

`<%= link_to "(forgot password)", new_password_reset_path %>`

```erb: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_with(url: login_path, scope: :session, local: true) 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>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/80959f47-27dc-5953-0571-66f093255ff2.png)

###新しいパスワードの設定
今までのように、トークン用の仮想的な属性とそれに対応するダイジェストを用意する。
トークン関連で学んだ事だが、トークンをハッシュ化せず(平文で)データベースに保存してしまうと、攻撃者がトークンを盗み出したときセキュリティ上問題がある。なので、ダイジェストを使おう。
また、再設定用リンクは期限を設ける。そのために、送信時刻を記録する必要がある。
以上を踏まえて、`reset_digest`属性と`reset_sent_at`属性をUserモデルに追加する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/f05a1c0e-158a-0153-c36b-c0f65b88bb54.png)
参照：railsチュートリアル

上記のUsersテーブルのするためマイグレーションを実行

```
$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
```

いつものようにマイグレーション
`$ rails db:migrate`

新しいパスワード再設定画面ビュー

```erb: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_with(url: password_resets_path, scope: :password_reset,
                    local: true) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.submit "Submit", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/f013843d-5259-d9a9-fa45-5f14e1ccba07.png)


演習
解答例
シンボルを使ってフォームを送信する事で、Railsが自動的に送信先に値を割り当てる。

###`create`アクションでパスワード再設定
Forgot passwordから送信後、メールアドレスをキーとしてユーザーをデータベースから探し、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する。
続いて、ルートURLにリダイレクトし、フラッシュメッセージを表示する。なお、送信が無効な場合は、`new`ページを出力して`fash.now`メッセージを表示する。

```rb: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モデルにもコードを追加していく。(`create_activation_digest`メソッドと似ている)

```rb:app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token


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

end
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/9e73f364-2d63-8ee1-f683-50ac25d8da6c.png)
この時点では、無効なメールアドレスを入力すると正常に動作する。
そして、正しいメールアドレスを送信した場合にも正常に動作させるには、パスワード再設定のメイラーメソッドを定義する必要がある。

演習
エラーメッセージの確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/223d7099-1c64-196f-69b8-3e732bf6b648.png)

##パスワード再設定のメール送信
パスワード再設定に関するメールを送信する部分を作成する。

第11章でUserメイラーを生成した時、デフォルトの`password_rest`メソッドもまとめて生成されている。

###パスワード再設定のメールとテンプレート
UserメイラーにあるコードをUserモデルに移すリファクタリングを行う。

`UserMailer.password_reset(self).deliver_now`

このコードの実装のために必要な`password_reset`メソッドを、Userメイラーに設定する。

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

パスワード再設定テンプレート(テキスト)

```rb: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.
```

パスワード再設定テンプレート(HTML)

```erb: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のメールプレビュー機能でプレビューを設定する。

```rb:test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview

  def password_reset
    user = User.first
    user.reset_token = User.new_token
    UserMailer.password_reset(user)
  end

end
```

メールのプレビュー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/0f8de2cb-a7de-0b91-dd50-5f2089c80b18.png)


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

```rb:test/mailers/user_mailer_test.rb
  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
```


##パスワードを再設定する
PasswordResetsコントローラの`edit`アクションの実装を進める。
また、統合テストでも行う。

###`edit`アクションで再設定
パスワード再設定の送信メールには、以下のようなリンクがある。

`https://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=fu%40bar.com`

このリンクを機能させるには、パスワード再設定のフォームを表示するビューが必要だ。

今回ちょっと面倒なのが、メールアドレスをキーとしてユーザーを検索するのに、`edit`アクションと`update`アクションの両方でメールアドレスが必要になる。
なので、最初はメールアドレス入りリンクのおかげで`edit`アクションはOK。
しかし、フォームを一度送信すると、この情報は消える。
故に、この値をどこかに保存しよう！となる。
そこで今回使うのは、隠しフィールド。これでページ内にメールアドレスを保持し、フォームから送信した時に他の情報と一緒にメールアドレスが送信されるようになる。

```erb: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_with(model: @user, url: password_reset_path(params[:id]),
                  local: true) 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`の検索とバリデーションを行う。

```rb: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
```

メールアドレスを入力し送信した結果にあったリンクから開くと、、、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/484da50e-61ab-c5ec-b4eb-2a4c46da07c1.png)
パスワード再設定用のページが出てきた。

演習
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1471120/addd6380-916b-e67b-48d8-c5ff39ca3617.png)
現時点では、パスワードを更新するボタンを押しても動作しない。

###パスワードを更新する
フォームからの送信に対応する`update`アクションが必要だ。

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

1、2、4はこれまでの知識で対応可能。

1は、`edit`と`update`アクションに以下のメソッドとbeforeフィルターを用意し対応する。


```rb
before_action :check_expiration, only: [:edit, :update]    # （1）への対応案
```

`check_expiration`メソッドは、有効期限をチェックするPrivateメソッドとして定義する。

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

上記のメソッドを呼び出しbeforeフィルターで保護したので、2と4のケースには対応できそう。
2は、無効なパスワードであれば失敗はOK
4は、更新が成功したらパスワードを再設定後、ログインさせればOK

問題としてパスワードが空文字だった時のケース。
以前Userモデルを作っていた時に、パスワードが空でも良いという実装をした。
従って、今回は明示的にキャッチする必要がある。
今回は、`@user`オブジェクトにエラーメッセージを追加するという方法を取る。
やり方は、`errors.add`を使ってエラーメッセージを追加する。

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

以上の結果から、1を除いたすべてのケースに対応した`update`アクションが完成する。

```rb:app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
 before_action :check_expiration, only: [:edit, :update]    # （1）への対応

  def update
    if params[:user][:password].empty?                  # （3）への対応
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update(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

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

`@user.password_reset_expired?`を動作させるため、`password_reset_expired?`メソッドをUserモデルに定義する。
2時間以上パスワードが再設定されなかった場合には、期限切れとする。

`reset_sent_at < 2.hours.ago`

不等号の「`<`」は、「より少ない」と読むのではなく「早い」と考えると良い。
今回は2時間より早い＝2時間以内捉えられる。

`password_reset_expired?`メソッドの実装。

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

  private
    .
    .
    .
end
```

これで全体の実装はOK


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

パスワード再設定のテストファイルを生成する。

`$ rails generate integration_test password_resets`

パスワード再設定手順
・「forgot password」フォームを表示して無効なメールアドレスを送信
・次にそのフォームで有効なメールアドレスを送信
・後者ではパスワード再設定用のトークンが生成され、再設定用のメールが送信される。
・続いて、メールのリンクを開き、無効な情報を送信
・次にそのリンクから有効な情報を送信
・それぞれが期待通りに動作しているかの確認を行う。

パスワード再設定の統合テスト

```rb: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'
    assert_select 'input[name=?]', 'password_reset[email]'
    # メールアドレスが無効
    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
```

新しいものとして、`input`タグがある。

```rb
assert_select "input[name=email][type=hidden][value=?]", user.email
```

これは、`input`タグに正しい名前、tyepe="hidden"、メールアドレスがあるか確認している。

```erb
<input id="email" name="email" type="hidden" value="michael@example.com" />
```

テストはOK

##本番環境でのメール送信
本番環境でのメール送信は第11章でやったので、あとはGitのトピックブランチをmasterにマージしておく。

```
$ rails test
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset
```

リモートレポジトリにプッシュし、Herokuにデプロイ

```
$ rails test
$ git push && git push heroku
$ heroku run rails db:migrate
```

##最後に
第11章と似ている個所もあったが、難しい章になったと思う。

残りの章では、マイクロポスト機能とフォローなどのステータスフィード機能を実装していく。

`has_many`や`has_many:through`が出てくる。