いわりょのBlog

IT関連で学んだことを書いていきます。

rails 基本的なログインシステム Part3 永続的なログイン機能 前編

すること

ユーザーのログインシステムの続き

  1. 管理人をユーザーモデルで作成
  2. ログインのcontroller作成やルーティング設定
  3. ログインのビュー作成
  4. ログイン機能作成
  5. ちょっと発展機能を加える
  6. ログイン機能を実装したことによって利用できること

4まできたので今回は5の発展的なログイン機能を追加していきます。
長くなりそうなので二回に分けて書こうと思います。

今回もどのように機能を実現するかだけ書いていきます。

前回ログイン機能と何が違うの?

ryo10leo.hatenablog.com
前回はクライアント側のブラウザにセッションデータを保持させることで、ログインを実現させていました。しかし、これだけでは問題があります。

セッションは永続的にデータを保持できない。

セッションデータは、クライアント側でブラウザを終了させた時点で破棄されてしまいます。
つまりログアウトしてしまうということ。
ということは、ふとしたときにブラウザを終了してしまった場合、またログインをやり直さなければなりません。ログインすると作業ができるアプリケーションの場合、何回もログインし直すのは面倒です。

永続的にデータを保持させる手段。

そこでクライアント側の判断によって、永続的にデータを保存してログイン状態を保持させる機能を作成します。これは、cookiesメソッドと、記憶トークンと呼ばれるデータを使用することで実現できます。

cookiesメソッドとは、クライアント側のブラウザに指定した期間だけ、データを保持させることができるメソッドです。セッションとは違い、ブラウザを閉じたとしても期間が過ぎるまではブラウザに保持させたデータは消えないということです。セッションデータを保持する時とは、また別の領域にデータを保存すると思ってください。

記憶トークとは、ユーザー固有のパスワードのようなものです。
ログインの時にフォームに入力するユーザーパスワードと異なる点は、パスワードはユーザーが作成するもの、トークンはコンピューター側が作成するという点です。

これを踏まえて具体的な永続的なログインの実現方法としては、

  • ユーザーに新たに記憶トークンの情報を保持させます。
  • 暗号化したユーザーID記憶トークの二つのデータをcookieメソッドでクライアント側のブラウザに保存します。

こうすることで何ができるのかと大まかに以下のような流れが実現できます。

  1. クライアントがブラウザを開いてページを見る。
  2. ブラウザのクッキーに保存されている、(暗号化された)ユーザーIDから(複合して)ユーザー検索。
  3. 検索して見つかったユーザーが保持している記憶トークンと、ブラウザが保持しているトークンが一致しているか検証。
  4. 一致していればログイン状態にする

といったことが実現されます。

作成方法

まずはユーザーに記憶トークンを保持させたり、記憶トークンを作成する機能を作っていきます。

remember_digestカラム

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

こうすることでユーザーに記憶トークンを保持させることができます。

$ rails generate migration add_remember_digest_to_users remember_digest:string
20191222022500_add_remember_digest_to_users.rb

class AddRememberDigestToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :remember_digest, :string
  end
end
$ rails db:migrate

記憶トークンの作成

記憶トークンはどんな文字列でも大丈夫です。
なぜならパスワードと違って個人が覚えておく必要がないからです。
しかし、セキュリティ上ランダムな文字列であることが推奨されています。
ランダムな文字列を生成するために、ここではSecureRandomモジュールにあるurlsafe_base64メソッドを使っていきます。
詳しくはドキュメントで↓
module SecureRandom (Ruby 2.7.0 リファレンスマニュアル)

こちらのメソッドを使って、トークンを作成するメソッドを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オブジェクトで使用することがないからです。
User.digestメソッドを使うことで記憶トークンをダイジェスト変換できます。。
User.new_tokenメソッドは、文字通り新規に記憶トークンを作成します。

rememberインスタンスメソッド

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

ここで注意するべき点は、Userモデルで作成したカラムは記憶トークンをハッシュ化したものを保存するremember_digestのみです。
つまりユーザーに紐付けずに、記憶トークンを作成し、それをremember_digestに保存してしまうと、せっかく作成した記憶トークンをクッキーに保存することができなくなってしまいます。

そこでカラムをわざわざ追加しなくても、ユーザーの記憶トークンを保持させる方法が、attr_accessorメソッドです。これは、仮の属性を作る役割を担っています。

よってrememberメソッドのコードは以下のようになります。

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

remember_digestにハッシュ化した記憶トークンを保存する前に、remember_tokenに記憶トークンを保存していますね。

ログイン状態の保持

ユーザーID、記憶トークンの二つのデータをcookieメソッドでクライアント側のブラウザに保存するコードを作成していきます。cookiesメソッドを使って実現します。

前回の記事のセッションで行ったsession[:user_id]にユーザーIDを入れたのと同様に、cookies[:user_id]にユーザーIDを入れます。さらにcookies[:remember_token]にremember_tokenを入れます。

cookies[:user_id] = user.id
cookies[:remember_token] = user.remember_token

仮属性で保持させた記憶トークンを使っていることにも注目してください。

ここでcookiesの永続化のため、さらにセキュリティーを今日かするために以下のように書き足す必要があります。

cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token

permanentメソッドは、「20年で期限切れ」になるcookies設定ではよく使われる内容を簡単に設定できるメソッドです。
signedメソッドは、署名付きcookieを実現できます。user.idの値を暗号化します。
また読み込みも行ってくれるので、保存されたユーザーIDを読み込みユーザーを検索する場合以下のように書きます。

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

先ほどのcookiesメソッド二つを、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メソッドは、まずuser.rbで作成したrememberインスタンスメソッドでユーザーとトークンを紐付けて、remember_digestカラムにトークンをハッシュ化して保存しています。その後cookiesにユーザーIDとトークンを入れています。

記憶トークンの照合

照合する方法は以下の通りです。

  1. cookiesに入ったトークンを取り出す。
  2. そのトークンをハッシュ化。
  3. Userオブジェクトのremember_digestカラムに入っているハッシュ値と比較する。

以下のコードでハッシュ値の比較が実現できます。

app/models/user.rb

 # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

remember_tokenはローカル変数。
remember_digestはUserモデルのremember_digest属性を指しています。

永続的なログインの実装

app/controllers/sessions_controller.rb
永続的なログインに必要なメソッドは揃いました!
Sessionsコントローラのcreateアクションを編集して、永続ログインを実装していきます。
ログインした後、rememberヘルパーメソッドを使用していることに注目してください。

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      remember user ←追加
      (ログイン後の処理はお好みで)
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

永続セッションからのログアウト

前回の記事でログアウトするとは、ブラウザに保持させた情報を破棄することと書きましたが、永続的なログイン状態を解除するためには何個か作業を追加する必要があります。

ログアウトで行うべきことは以下の3つです。

  1. ログアウトするユーザーのremember_digestを空にする
  2. cookiesのユーザーIDを削除
  3. cookiesの記憶トークンを削除

具体的にコードを書いていきます。

Userモデルでは、Userオブジェクトのremember_digest属性にトークンをハッシュ化して保存知るrememberメソッドを定義しました。なのでここではその逆で、remember_digest属性をからにするforgetメソッドを作成します。

app/models/user.rb

  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end

次に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メソッドを修正します。

app/helpers/sessions_helper.rb

  # 現在のユーザーをログアウトする
   def log_out
    forget current_user
    session.delete(:user_id)
    @current_user = nil
  end

current_userとか@current_userってなんぞ?
これは最後の記事で笑

残りは、リメンバーミーボタン!

あとリメンバーミーボタンも必要です。
次回はこれらを修正、作成していきます。