自動ログイン(Remember me)機能を実装する

[TODOアプリ](https://qiita.com/d0ne1s/items/5e63dde992f20f25b8bb)に、自動ログイン機能を追加します。

[以前の記事](https://qiita.com/d0ne1s/items/7c4d2be3f53e34a9dec7)で、sessionメソッドを使ってユーザーIDを保存しましたが、この情報はブラウザを閉じると消えてしまいます。

毎回ログインし直すのは少し不便なので、ログインフォームの「**Remember me**」チェックボックスにチェックをしてログインした場合には、再訪時に自動でログインできるように修正していきましょう。


##処理の流れ
1. 「Remember me」にチェックを入れてログインした時、**記憶トークン**（ランダムな文字列）と**記憶ダイジェスト**（記憶トークンをハッシュ化したもの）を作成

2. 記憶トークンと暗号化したユーザーIDを**cookieに保存**し(有効期限20年）、記憶ダイジェストを**DBに保存**する

3. Sessonの無いユーザーがサイトを訪問した時、cookieにユーザーIDがあれば、その**IDをキーにDBを検索**

4. 検索して取得したユーザーの記憶ダイジェストと、cookieの記憶トークン(をハッシュ化したもの)を**比較**

5. 一致すれば**Sessionを開始**する

6. （明示的な）ログアウト時にcookieのユーザーID、記憶トークン、DBの記憶ダイジェストを**全て削除**する

※トークン：コンピューターによって作成、管理されるパスワードのこと

それでは、以下よりシステムを修正していきます。

##Modelの修正
###Userモデルに`remember_digest`属性を追加
```terminal
$ rails g migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
```
マイグレーション名の末尾を`_to_users`にすることで、マイグレーションの対象がデータベースのusersテーブルであることをRailsに指示しています。

###`remember_token`属性を有効化
`remember_token`属性はDBには存在しない属性です。`attr_accessor`を使って、仮想的に利用できるようにします。

```app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token
.
.
```

##Viewにチェックボックスを追加
フォームの後半に以下の記述を追加し、「**Remember me**」チェックボックスを表示します。
（後ほど、チェックが入っていた時にだけセッションを永続化する処理を実装していきます）

```erb:app/views/sessions/new.html.erb
<%= f.label :remember_me do %>
  <%= f.check_box :remember_me %>
  <span>Remember me on this computer</span>
<% end %>
```
行全体をラベルで囲んでいるので、行のどこの部分でもクリックできるようになっています。

##ログイン用メソッドを作成
Controllerでログイン永続化の処理を簡単に記述できるように、ログインに使う各種メソッドを作成していきます。
###トークン生成用メソッド
長くてランダムな文字列が作れれば方法は何でも構いません。ここではRuby標準ライブラリの`SecureRandomモジュール`にある`urlsafe_base64メソッド`を利用します。

```app/models/user.rb
# ランダムなトークンを返す
def self.new_token #User.new_tokenと同じ意味
  SecureRandom.urlsafe_base64
end
```

###ハッシュ化用メソッド
記憶トークンをハッシュ化して記憶ダイジェストを作成するために、ハッシュ化用のメソッドを用意します。

```app/models/user.rb
# 与えられた文字列のハッシュ値を返す
def self.digest(string) #User.digest(string)を同じ意味
  cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                BCrypt::Engine.cost
  BCrypt::Password.create(string, cost: cost)
end
```

###記憶トークンと記憶ダイジェストを生成するメソッド
上記で作成した2つのメソッドを使って、
・記憶トークンの生成→ユーザーに紐付け
・記憶ダイジェストの生成→DBに登録
の2つを行うメソッドを作成します。

```app/models/user.rb
# 永続的セッションで使用するユーザーをデータベースに記憶する
def remember
  self.remember_token = User.new_token
  update_attribute(:remember_digest, User.digest(remember_token))
end
```

###記憶トークンと記憶ダイジェストを比較するメソッド
パスワードの認証の時には、`authenticate`メソッド（**has_secure_password**と記入するだけで作られるメソッド）を利用して、受け取ったパスワードとDBのパスワードダイジェストを比較しました。

今回は、比較用のメソッドを自分で作る必要があります。

```app/models/user.rb
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
```
`is_password?`を使って比較しているのは、`BCrypt`のソースコードを読み解いた結果このメソッドが使えることがわかったからです。

###ユーザーID、記憶トークン、記憶ダイジェストを登録するヘルパーメソッド
暗号化したユーザーIDと、記憶トークンをcookieに、記憶ダイジェストをDBに登録するヘルパーメソッドを作成します。
**今回の実装の要となるメソッドです**。
ここまでのステップは、全てこのメソッドを作成するためにあったと言っても過言ではありません。

```app/helpers/sessions_helper.rb
# ユーザーのセッションを永続的にする
def remember(user)
  user.remember #Userモデルで定義したrememberメソッド。記憶トークンを作成、ハッシュ化してDBに保存
  cookies.permanent.signed[:user_id] = user.id #ユーザーIDを暗号化してcookieに保存
  cookies.permanent[:remember_token] = user.remember_token #記憶トークンをcookieに保存
end
```
`permanent`を使ってcookieを永続化し(有効期限を20年に設定)、
`signed`を使ってユーザーIDを暗号化しています。

取り出しの際には`id = cookies.signed[:user_id]`などで取り出すことができます。

##ログイン処理を修正
###ログイン後に`remember`メソッドを実行
作成した`remember`メソッド（ヘルパーメソッドの方）をログイン後に実行するよう、Createアクションを修正します。
チェックボックスにチェックがなかった場合に実行する`forget`メソッドは、後ほど実装します。

```app/controllers/sessions_controller.rb
def create
  user = User.find_by(email: params[:session][:email].downcase)
  if user && user.authenticate(params[:session][:password])
    log_in user
    #remember_meにチェックが入って入れば記憶トークン等を登録、チェックが無ければ記憶トークン等を削除
    params[:session][:remember_me] == '1' ? remember(user) : forget(user)
    redirect_to user
  else
.
.
```

###ログイン中のユーザーを取得するメソッドを修正
ログイン中のユーザーを取得する`current_user`メソッドを、上から下のように修正します。

セッションがなくてもすぐにあきらめず、cookieのユーザーIDと記憶ダイジェストを使ってユーザーを取得するようになりました。

**修正前**

```app/helpers/sessions_helper.rb
def current_user
  if session[:user_id]
   @current_user ||= User.find_by(id: session[:user_id])
  end
end
```

**修正後**

```app/helpers/sessions_helper.rb
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end
```
ユーザーIDと記憶ダイジェストを使ってユーザーを取得した後には、`log_in`メソッドでセッションも開始しています。


##ログアウト用メソッドを作成
###記憶ダイジェストをnilで更新するメソッド
```app/models/user.rb
# ユーザーのログイン情報を破棄する
def forget
  update_attribute(:remember_digest, nil)
end
```

###ユーザーID、記憶トークン、記憶ダイジェストを全て削除するメソッドを作成
作成した`forget`メソッドを使って、セッション永続化に関わる全ての値を削除するヘルパーメソッドを作ります。

```app/helpers/sessions_helper.rb
# 永続的セッションを破棄する
def forget(user)
  user.forget #Userクラスのforgetメソッド。DBの記憶ダイジェストにnilを登録。
  cookies.delete(:user_id) #cookieのユーザーIDを削除
  cookies.delete(:remember_token) #cookieの記憶トークンを削除
end
```

##ログアウト処理を修正

###ログアウト用メソッドを修正
作成したヘルパーメソッド`forget`を、ログアウト用ヘルパーメソッド`log_out`から呼び出します。

```app/helpers/sessions_helper.rb
# 現在のユーザーをログアウトする
def log_out
  forget(current_user) #この行を追加
  session.delete(:user_id)
  @current_user = nil
end
```

##細かなバグを修正

現状、2つの細かいバグが存在するので、修正していきます。

###バグ1
**1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーが発生する**
→1度ログアウトすると(`log_out`メソッドを実行すると)、`current_user`の値が`nil`になるので、2回目の`log_out`メソッドが失敗する。
→ログアウト前に、ログイン状態を確認するように修正する。

```app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  def destroy
    log_out if logged_in? #この行を修正
    redirect_to root_url
  end
end
```

###バグ2
**Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、再度Chromeで同じページを開くとエラーが発生する**
→片方のブラウザでログアウトしても、もう片方のブラウザにはcookie（ユーザーIDと記憶トークン）が残り続ける
→DBの記憶ダイジェストは削除されているのに、記憶トークンだけが残っている
→記憶トークンと記憶ダイジェストの比較の際にエラーが発生
→比較の前に、記憶ダイジェストの有無をチェックする

```app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    return false if remember_digest.nil? #この行を追加
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end
```
##参考
[第8章 ログイン、ログアウト - Railsチュートリアル](https://railstutorial.jp/chapters/log_in_log_out?version=4.2#sec-remember_me)