Rails Tutorial 9章 ログイン機構の自分なり解釈まとめ


ユーザーを記憶するための手段(RailsTutorial9章)

Cookiesメソッドを用いた永続ログインの方法

Cookiesメソッドによる、永続ログインの方法について、概要をバサッとまとめてみました。

  • 永続ログインを可能にするために、ブラウザのcookieに記憶トークンと呼ばれる文字列を保存する。

  • この記憶トークンをセキュアな文字列にした記憶ダイジェストをデータベースに保存する。

  • この記憶トークンをデータベースのセキュアな文字列と比較し、照合する。

  • 署名付き暗号化user_idもcookieに保存し、データベースのuser.idと比較する。

記憶トークンの生成及び、ハッシュ値のデータベースへの保存

記憶トークンはSecureRandom の urlsafe_base64を使い生成
記憶ダイジェストは

/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

のような関数でUser.digestの引数に代入した値をBcryptでハッシュ化する。
ここでは、インスタンスメソッドで定義する必要はないので、クラスメソッドで定義している。
引数に代入する値は生成したランダムな64文字の文字列である記憶トークンを用いる。

/models/user.rb

def User.new_token
    SecureRandom.urlsafe_base64
end

この二つの過程を合体させて生成した記憶トークンをハッシュ化した値をデータベースのremember_digestに保存する
この時、記憶トークンの属性も別途設定しておく

/models/user.rb

attr_accessor :remember_token


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

注意 self.remember_tokenとしないとremember_tokenのローカル変数を定義してしまうことになる。

current_userにログイン中のユーザー情報を保持させる

1.sessionがまず存在するかどうかを確かめる

2.ない場合はcookiesに署名されたuser_id (つまりcookies.signed[:user_id])が存在するか確かめる

3.user=User.find_by(id:cookies.signed[:user_id])でUser モデルにcookiesのuser_idをもつユーザーが存在するか確かめる
注意 cookies.signed[:user_id]は暗号化されているが、自動的に暗号化が解除されている。

4.このuserが存在、つまりはtrueであり、かつ、そのuserのremember_digestとcookies[:remember_token]のハッシュ値が一致するとき、つまり

if user&& user.authenticated?(cookies[:remember_token]) ・・・・・注意
@current_user=user
def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

としてcookiesの中のremember_tokenキーに含まれるトークンのハッシュ値が、userのremember_digestと一致するとき、
ログイン中にする。(@current_userにuserを代入する。)

1. ー4.をまとめてコードにすると

def current_user
    if (user_id=session[:user_id])
        @current_user||=User.find_by(id: user_id) #@current_userが存在する場合はそのまま、ない場合はデータベースからActive Recordのfind_byメソッドを用いて検索
    elsif (user_id=cookies.signed[:user_id])
        user=User.find_by(id: user_id)
        if (user && user.authenticated?(cookies[remember_token]))#userが存在し、cookiesに保存されているトークンのハッシュ値とremember_digestが一致する場合
            @current_user=user
        end
    end
end

注意:ここでuser_id=session[:user_id]とあるがこれは比較をしているのではなく、
user_idにsession[:user_id]を代入した際に存在すれば。の意味である。

また、ヘルパーでcookiesに署名付き暗号化user.idを保存し、64文字のトークンを保存することで、
cookiesからユーザー情報をトークンで取り出せるようにする。

app/helpers/session_helper.rb

def remember(user)
    user.remember #データベース側にremember_digestを保存
    cookies.permanent.signed[:user_id]=user.id #署名付き暗号化user_idをcookies側に保存
    cookies.permanent[:remember_token]=user.remember_token #remember_tokenをcookies側に保存
end

これでcurrent_userにログイン中のユーザーの記憶する手段が整った。

ログイン時に行われるログインの永続化の処理

/session_controller.rb

def create
    user=User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticated?(params[:session][:password])
        log_in (user) #ヘルパーで定義 #current_userにuserを代入
        remember(user)#ヘルパーで定義 #cookiesにremember_token及びuser_idを保存
        redirect_to user_url(user)
    else 
        flash.now[:danger]="Invalid email/password combination"
        render 'new'
    end
end

Logoutするとき

ログアウトするときは、

  • cookies.signed[:user_id]の削除
  • cookies[:remember_token]の削除
  • remember_digestをnilにする
  • sessionの情報も破棄する
  • current_userもnilにする
models/user.rb
def forget
 update_attribute(:remember_digest,nil)
end
helpers/sessions_helper.rb
def forget(user)
 user.forget
 cookies.delete(:user_id)
 cookies.delete(:remember_token)
end
helpers/sessions_helper.rb
def logout
  forget(current_user)
  session.delete(:user_id)
  @current_user=nil
end
helpers/sessions_helper.rb
  def destroy
    log_out if logged_in? 
    redirect_to root_url
  end

ここで

log_out if logged_in?

としている理由としては
ブラウザが2つ起動していて、片方のブラウザでログアウト処理をした場合に、current_userがnilになるのでforget(current_user)の引数にしているオブジェクトがnilになるのでエラーをはくためです。

もう一つエラーがあります。
同じようにブラウザが2つ起動していて、片方でログアウト処理をしたとき、remember_digestの値がnilになるので、もう片方のブラウザで一度ブラウザを閉じて、再びつけたときに、cookieは存在するので、下の式のcookies.signed[:user_id]が評価されるので次のif文まで評価されますが、authenticated?メソッドで使っているBcrypt.newは引数であるremember_digestがnilになってしまいエラーになってしまいますので、remember_digestがnilの場合はfalseを返すようにします。

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])# BCryptで生成するdigestの値がnilとなりエラーが発生する。
      log_in user
      @current_user = user
    end
  end
end
def authenticated?(remember_digest)
 return false if remember_digest.nil? #remember_digestがnilの場合はfalseを返して、条件式を終了させる。
 BCrypt.new(remember_digest).is_password?(remember_token)
end

まとめ

  • cookiesではログインするための情報を保存する
    • 署名付き暗号化user_id
    • 記憶トークン
  • ログインするときは、sessionから照合し、ない場合は、cookiesの情報を参照する。

  • データベースにはハッシュ化されたトークンを保存しておき、authenticated?ヘルパーにより、cookiesの記憶トークンと照合する。

  • ログアウトするときはcookiesやデータベースのremember_digestをnilにする。