Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #9 永続セッション, cookie編

# こんな人におすすめ
* プログラミング初心者でポートフォリオの作り方が分からない
* Rails Tutorialをやってみたが理解することが難しい

前回：[#8 ログイン/ログアウト, FactroyBot編](https://qiita.com/aokyo17/items/9d464e5ee8838557d4ad)
次回：[#10 リメンバーミー機能編](https://qiita.com/aokyo17/items/786f6cb316a2323c0bf2)

# 今回の流れ
1. 今回のゴールを把握する
2. 永続化の仕組みを確認する
3. 永続化に必要な属性を作る
4. 永続化に必要なメソッドを作る
5. Sessionsコントローラーを修正する
6. current_userメソッドを修正する
7. Userモデルのテストを作る

※ この記事は、ポートフォリオを作る理由を**web系自社開発企業に転職するため**とします。
※ 2020年4月6日、記事を大幅に更新しました。

# 今回のゴールを把握する
ゴールは、ログインを永続化する機能を完成させることです。
永続化を、ユーザー自身に選択させるチェックボックスは、[#10](https://qiita.com/aokyo17/items/786f6cb316a2323c0bf2)で作ります。

まずは、永続化の仕組みを確認します。
その際、永続化に必要なメソッドを確認します。

続いて、Userモデルにremember_digest属性を作ります。

続いて、永続化に必要なメソッドを作ります。
その際、複雑なコード記述について確認します。

続いて、ログイン時に永続化するようSessionsコントローラーを修正します。
続いて、クッキーを参照するようcurrent_userメソッドを修正します。

最後に、Userモデルのauthenticated?メソッドをテストします。
他の永続化に関するテストは、次回リメンバーミー機能を追加してからにします。

以上です。

# 永続化の仕組みを確認する
永続化は、以下の手順で行われます。

1. ログイン時に、トークン、暗号化したトークン、暗号化したIDの3つを作る
2. トークンと暗号化したIDをcookiesに、暗号化したトークンをDBに保存する
3. 再びWebページに訪れた時、暗号化したIDからユーザーを特定する
4. ユーザーを特定したら、トークンと暗号化したトークンを照合する
5. 照合が成功したら、自動でログインする
6. （永続化しない場合）、永続化を破棄する

以上から、使用するメソッドは以下の通りです。

* トークンを作る → User.new_tokenメソッド
* 暗号化したトークンを作る → User.digestメソッド
* 暗号化したIDを作る → cookies.signedメソッド
* トークンと暗号化したIDをcookiesに代入する → cookies.signedメソッド
* 暗号化したトークンをDBに代入する → remember（モデル）メソッド
* 作った3つを保存する → remember（ヘルパー）メソッド
* 暗号化したIDからユーザーを特定する → cookies.signedメソッド
* トークンを照合する → authenticated?メソッド
* 永続化を破棄する → forget（モデル）メソッド、forget（ヘルパー）メソッド 

メソッドを使うには、暗号化したトークンをDBに保存するための属性が必要です。
その為、先にremember_digest属性を作ります。
その後、メソッドを作ります。

参考になりました↓
[クッキー（cookie）とは？初心者でも分かるように図解](https://saruwakakun.com/it/web/cookie-cache)

# 永続化に必要な属性を作る
Userモデルにremember_digest属性を加えます。
後述するメソッドを作る際に必要です。

```shell:shell
$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
```

# 永続化に必要なメソッドを作る
永続化に必要なメソッドを作ります。
以下が、完成したメソッドです。

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

  # 中略

  # この部分はクラスメソッドです。先に解説します。
  class << self
    def new_token
      SecureRandom.urlsafe_base64
    end

    def digest(string)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                    BCrypt::Engine.cost
      BCrypt::Password.create(string, cost: cost)
    end
  end

  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
  
  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
  
  def forget
    update_attribute(:remember_digest, nil)
  end
end
```

```rb:app/helpers/sessions_helper.rb
module SessionsHelper

# 中略

  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end
end
```

各メソッドを確認する前に、クラスメソッドについて確認します。

## クラスメソッドについて知る
User.digestやUser.new_tokenはクラスメソッドです。
クラスメソッドは、このように定義します。

```rb:書き換え前
def User.digest(string)
end

def User.new_token
end
```

上記は、以下のように書き換えることができます。

```rb:書き換え後
class << self
  def digest(string)
  end

  def new_token
  end
end
```

前回までに定義してきたメソッドは、インスタンスメソッドと呼びます。
クラスメソッドとインスタンスメソッドの違いは、以下の通りです。

* クラスメソッド → クラスオブジェクトから呼び出すメソッド
* インスタンスメソッド → インスタンスオブジェクトから呼び出すメソッド

例をあげます。

```rb:
# インスタンスオブジェクトを生成します
user = User.find(1)

# 以下のように呼び出せるのがインスタンスメソッドです
user.hoge
# 上記のように呼び出せず、以下のように呼び出すのがクラスメソッドです
User.hoge
```

では、なぜクラスメソッドを使う必要があるのでしょうか。
理由は、**ユーザー情報が不要なメソッドを明示的に分けるため**です。

トークンを作るだけ、暗号化するだけの処理には、ユーザ情報が不要です。
反対に、remember_digestは個々のユーザーが持つべき情報です。
その場合、インスタンスメソッドになります。

その他、クラスメソッドはクラスそのものの変更や参照にも使われます。

参考になりました↓
[Rubyのクラスメソッドとインスタンスメソッドの例](https://qiita.com/tbpgr/items/56eb65c0ea5882abbb07)
[【Ruby】クラスメソッドとインスタンスメソッドについてザクッと分かりやすく説明してみる](https://qiita.com/amidara/items/27221675638cdc9bde6b)

## 各メソッドを確認する
完成した各メソッドを確認します。全部で7つです。
メソッドの種類ごとに、改めてこちらに載せておきます。
（cookies.signedメソッドは標準で用意されているので、自ずとリストから外れます）

**モデルメソッド**

* トークンを作る → User.new_tokenメソッド
* 暗号化したトークンを作る → User.digestメソッド
* 暗号化したトークンをDBに代入する → remember（モデル）メソッド
* トークンを照合する → authenticated?メソッド
* 永続化を破棄する → forget（モデル）メソッド

**ヘルパーメソッド**

* 作った3つを保存する → remember（ヘルパー）メソッド
* 永続化を破棄する → forget（ヘルパー）メソッド

順に理解します。

### User.new_token
トークンを作るメソッドです。
ランダムな文字列を生成する、urlsafe_base64メソッドを使っています。

```rb:
class << self
  def new_token
    SecureRandom.urlsafe_base64
  end
end
```

### User.digest
暗号化したトークンを作るメソッドです。
BCryptから、ハッシュ値を返すメソッドを借りることで成立します。

```rb:
class << self
  def digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
end
```

参考になりました↓
[ハッシュ値 (hash value)とは](https://wa3.i-3-i.info/word11949.html)

### remember（Userモデルメソッド）
暗号化したトークンをremember_digest属性に代入するメソッドです。

暗号前のトークンを一時的に扱うためには、仮属性remember_tokenを使います。
仮属性により、暗号前のトークンをユーザーと関連付けることができます。
同時に、暗号前のトークンをDBに保存せずに済みます。

仮属性を使うには、attr_accessorを使います。

```rb:
attr_accessor :remember_token

def remember
  self.remember_token = User.new_token
  update_attribute(:remember_digest, User.digest(remember_token))
end
```

参考になりました↓
[Rubyのattr_accessorって何？[和訳]](http://bryankawa.hatenablog.com/entry/2017/01/28/150537)
[Railsから入った人へ【attr_accessor】って？](https://qiita.com/Hassan/items/0e034a1d42b2335936e6)
[永続cookiesでガチセッションするRailsチュートリアル9章](https://www.masalog.site/entry/2017/08/31/171712)

### authenticated?
トークンを照合するメソッドです。
BCryptから、照合するメソッドを借りることで成立します。

このメソッドの、引数remember_tokenはローカル変数です。
このメソッドの、引数remember_digestはUserモデルの属性です。

1行目のif文は、複数のブラウザを同時に立ち上げた時のエラーを回避します。
片方のブラウザでログアウトしても、もう片方のブラウザにはクッキーが残ります。
この混線でcurrent_userが例外にならないよう、falseを代入します。
（詳しくは、後述の「current_userメソッドを修正する」をご覧ください。）

```rb:
def authenticated?(remember_token)
  return false if remember_digest.nil?
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
```

### forget（Userモデルメソッド）
永続化を破棄するメソッドです。
remember_digestにnilを代入することで成立します。

```rb:
def forget
  update_attribute(:remember_digest, nil)
end
```

### remember（Sessionsヘルパーメソッド）
トークン、暗号化トークン、暗号化IDを保存するメソッドです。

1行目にrememberヘルパーメソッド内で、rememberモデルメソッドを使っています。
つまりremember_tokenからremember_digestへの代入も行っています。

IDの暗号化には、cookies.signedを使います。
クッキーを長期間保存するには、cookies.permanentを使います。

```rb:
def remember(user)
  user.remember
  cookies.permanent.signed[:user_id] = user.id
  cookies.permanent[:remember_token] = user.remember_token
end
```

### forget（Sessionsヘルパーメソッド）
永続化を破棄するメソッドです。
モデルメソッドと異なるのは、クッキー側の破棄も行う点です。

```rb:
def forget(user)
  user.forget
  cookies.delete(:user_id)
  cookies.delete(:remember_token)
end
```

# Sessionsコントローラーを修正する
ログイン時に永続化するよう、Sessionsコントローラーを修正します。
先ほど、モデルメソッドでヘルパーを準備しました。
そのおかげで、ヘルパーのrememberメソッドを使うだけで永続化が成立します。

```rb:app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  # 中略
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      remember user
      redirect_to user
    else
      flash.now[:danger] = 'メールアドレスかパスワードが正しくありません'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end
```

destroyアクションには、if文が追加されています。
これは、複数タブで同一Webページをみる際のエラーを回避しています。
このエラーは、一方のタブでログアウトしても、もう片方のタブに情報が残るために発生します。

log_outメソッドを確認すると、より具体的になります。
ログアウトするとcurrent_userにはnilが代入されます。
そこで再びログアウトしようとすると、forgetメソッドにnilを代入しようとします。
これがエラーの原因です。

```rb:
def log_out
  forget(current_user)
  session.delete(:user_id)
  @current_user = nil
end
```

# current_userメソッドを修正する
クッキーを参照するようにcurrent_userメソッドを修正します。
current_userは以下のように、ユーザーを取得します。

* session[:user_id]が存在すれば、一時セッションからユーザーを取得する
* cookies[:user_id]が存在すれば、永続セッションからユーザーを取得する

```rb:app/helpers/sessions_helper.rb
module SessionsHelper
# 中略
  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
# 中略
```

authenticated?メソッドでのエラー対応は、このif文への対応です。

複数ブラウザのうち一方をログアウトすると、remember_digestにnilを代入します。
しかし別ブラウザでクッキーが残ります。
そのためelsifを評価してしまいます。

ここでauthenticated?メソッドを確認すると、より具体的になります。
if文がなければ、BCrypt::Passwordクラスのnewメソッドにnilが代入されます。
このためのエラー回避です。

```rb:
def authenticated?(remember_token)
  return false if remember_digest.nil?
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
```

参考になりました↓
[35歳だけどRailsチュートリアルやってみた。［第4版 9章 9.1 Remember me 機能 まとめ&解答例］](https://qiita.com/yokoyan/items/fe7a74a09181b1840f9a)

# Userモデルのテストを作る
Userモデルのauthenticated?メソッドをテストします。
他の永続化に関するテストは、次回リメンバーミー機能を追加してからにします。

```rb:spec/models/user_spec.rb
# 中略
describe "User model methods" do
  describe "authenticated?" do
    it "return false for a user with nil digest" do
      expect(user.authenticated?('')).to be_falsey
    end
  end
end
```

今回は以上です。

---

前回：[#8 ログイン/ログアウト, FactroyBot編](https://qiita.com/aokyo17/items/9d464e5ee8838557d4ad)
次回：[#10 リメンバーミー機能編](https://qiita.com/aokyo17/items/786f6cb316a2323c0bf2)