【Rails】cookiesによる永続的なログイン【Rails Tutorial 9章まとめ】

##永続ログインとcookies
一時的なログインの維持にはsessionメソッドを使用する。
しかし、ブラウザを閉じると自動的にログアウトしてしまう。

ログイン状態を維持するためには、cookiesメソッドと、記憶トークンと呼ばれるデータを使用する。
記憶トークンはユーザーごとに固有な、パスワードのようなものである。
具体的な手順はこうなる。
①ユーザーごとに記憶トークンを作成する。
②記憶トークンをハッシュ化してUserモデルのカラム(remember_digest)に保存する。
③cookiesに記憶トークンとユーザーIDを入れる。
④ユーザーIDが入ったcookiesを受け取ったら、そのIDをもとにデータベースからユーザーを探し出し、そのremember_digestカラムに保存されているハッシュ値と、cookiesに入っているトークンをハッシュ化した値が一致するか確認する。

##記憶トークンの生成と保存
###remember_digestカラム
マイグレーションファイルを作成して、Userモデルにremember_digestカラムを追加する。
このカラムには、記憶トークンをハッシュ化して保存する。

```console
$ rails generate migration add_remember_digest_to_users remember_digest:string
```

```db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :remember_digest, :string
  end
end
```

###記憶トークンの生成
記憶トークンはどんな文字列でもよいが、パスワードと違って個人が覚えておく必要がなく、かつセキュリティ上ランダムな文字列であることが望ましい。
ランダムな文字列を生成するために、ここではSecureRandomモジュールにあるurlsafe_base64メソッドを使う。

```console
$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"
```
これを使って、トークンを生成するメソッドをUserモデルに定義する。

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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end
```
このメソッドを個々のUserオブジェクトで使用することはないので、クラスメソッドとしている。

###rememberインスタンスメソッド
User.new_tokenメソッドで生成したトークンをユーザーに結びつけ、remember_digestカラムにハッシュ化して保存するrememberメソッドを定義する。

ところで、パスワードを保存する場合は次のような手順を踏んでいた。
①passwordというUserモデルの仮想の属性に生のパスワードを入れる。
つまり、@user.password = "foobar"のようにしていた。
②それがbcryptとhas_secure_passwordによってハッシュ化されてpassword_digestカラムに保存される。

これをトークンでも同じようにするのだが、トークンにはbcryptのような便利なものが無いので、今回はpassword属性に相当する仮想のremember_token属性を自分で作らねばならない。
仮想の属性を作るためには、attr_accessorを使う。

```app/models/user.rb
attr_accessor :remember_token
.
.
.
 # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
```
attr_accessorで作った仮想の属性をメソッドの中で使うには、self.を頭につける。
(ところで、@remember_tokenではダメなんだろうか？)
update_attributeメソッドを使って、remember_digestカラムにハッシュ化したremember_tokenを代入する。

##ログイン状態の保持
###cookiesとrememberヘルパーメソッド
記憶トークンを作成し、各ユーザーのデータベースに保存できるようになったので、次はcookiesメソッドに記憶トークンとユーザーIDを入れる。
session[:user_id]にユーザーIDを入れたのと同様に、cookies[:user_id]にユーザーIDを、cookies[:remember_token]にremember_tokenを入れる。

```rb
cookies[:user_id] = user.id
cookies[:remember_token] = user.remember_token
```
ここで、cookiesを永続化するために.permanentメソッドを使用する。
また、ユーザーIDはセキュリティ上の問題から.signedメソッドを使用して暗号化する。
これは署名つきcookieと呼ばれる。

```rb
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
```
署名つきcookieは自動で複合化されるので、そのままユーザー検索に使える。

```rb
User.find_by(id: cookies.signed[:user_id])
```

これをSessionsコントローラのログイン部分で使用するために、Sessionsコントローラのヘルパーメソッドとして定義する。

```app/helpers/sessions_helper.rb
  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # ユーザーのセッションを永続的にする
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end
```
ここで非常にややこしいと思うのだが、このrememberヘルパーメソッドはデータベースにトークンを保存するために定義したrememberメソッドとは別物である。
前者はSessionsコントローラのアクション内で単独で使うが、後者はUserオブジェクトにつけて使うインスタンスメソッドである。

このメソッドは、まず後者のrememberインスタンスメソッドでユーザーとトークンを紐付け（remember_digestカラムにトークンをハッシュ化して保存）、その後cookiesにユーザーIDとトークンを入れている。

###記憶トークンの照合
cookiesに入ったトークンをハッシュ化して、Userオブジェクトのremember_digestカラムに入っているハッシュ値と比較する。
これが少し分かりにくく感じるが...
①cookiesには生のトークンを入れる。
②remember_digestにはハッシュ化されたトークンが入っている。
③cookiesの生のトークンをハッシュ化してみて、それが②と一致するか確かめる。
ということらしい。
これはログイン機能で使用したauthenticateメソッドと同じである。
authenticateメソッドは次のように定義されている。

```rb
BCrypt::Password.new(password_digest)is_password?(unencrypted_password)
```
左の()内にあるのはpassword_digestカラムに入っているハッシュ化されたパスワードである。
右の()内にあるのは生のパスワードである。

これをトークンに当てはめるとこうなる。

```rb
BCrypt::Password.new(remember_digest).is_password?(remember_token)
```
これをもとに、authenticated?メソッドをUserモデルに定義する。

```app/models/user.rb
  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
```
ここで使われているremember_tokenはこのauthenticated?メソッドでのみ使用できるローカル変数で、attr_accessorで作成したUserモデルの仮の属性ではない。
（これはややこしいので、適宜名前を変えた方がよいと思う。）
左の(remember_digest)はUserモデルのremember_digest属性のことで、self.remember_digestと同じである。

##永続的なログインの実装
必要なメソッドが揃ったので、Sessionsコントローラのcreateアクションを編集して、永続ログインを実装する。
ログインした後、rememberヘルパーメソッドを使用する。

```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 user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
```

###current_userメソッドの修正
永続ログイン機能を実装できたが、一つ問題がある。
現在ログインしているユーザーを返すcurrent_userメソッドは、session[:user_id]からユーザーを探している。
ログインフォームからログインした場合はsessionにもcookiesにもユーザーIDを入れるので問題ない。
しかし、その後ブラウザを閉じると、cookiesは残るがsessionが消えてしまうので現在のユーザーが取り出せなくなる。
つまりログインしているのに現在のユーザーを見つけられないことになる。

これを、sessionが無い場合はcookiesからユーザーを探すように修正する。

```app/helpers/sessions_helper.rb
  # 記憶トークンcookieに対応するユーザーを返す
  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
```
cookiesからユーザーを見つけたら、（この時点ではログインできていないはずなので）ログインする。
ここは、ログイン時にemailからユーザーを見つけ、パスワードをauthenticateメソッドで照合するのと全く同じ要領である。
cookies[:user_id]からユーザーを見つけ、トークンをauthenticated?メソッドで照合する。
最後に、現在のユーザーを返す。

なお、sessionとcookiesは繰り返し処理を省くために、if文の条件式でローカル変数user_idに入れてから使っている。

##永続セッションからのログアウト
永続ログイン状態を解除するために、永続ログインで作った２つのrememberメソッドにそれぞれ対応し、逆の操作をする２つのforgetメソッドを定義して、ログアウト処理に組み込む。
###forgetインスタンスメソッド
Userモデルのrememberメソッドでは、Userオブジェクトのremember_digest属性にトークンをハッシュ化して保存した。
forgetメソッドはその逆で、remember_digest属性を空にする。

```app/models/user.rb
  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
```

###forgetヘルパーメソッド
Sessionsヘルパーのremember(user)メソッドでは、rememberインスタンスメソッドを呼び出した後、cookiesにユーザーIDとトークンを入れた。
forget(user)メソッドはその逆である。

```app/helpers/sessions_helper.rb
  # 永続的セッションを破棄する
   def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end
```

###log_outメソッドの修正
最後に、log_outメソッドにforget(user)メソッドを追加すればログアウト処理が完成する。

```app/helpers/sessions_helper.rb
  # 現在のユーザーをログアウトする
   def log_out
    forget current_user
    session.delete(:user_id)
    @current_user = nil
  end
```
##2つのバグ
完成した永続ログイン機能は、小さなバグが２つあるらしい。
…が、今のところ重要ではなさそうなので割愛する。

##永続ログインのテスト
テスト部分は別記事にまとめることにする。