ログイン機能(3) Ruby on Rails

ログイン機能(2)までで、sessionメソッドを使ってユーザーIDを一時的セッションに保存しました。これは、ブラウザを閉じると破棄されてしまいます。これでも、問題はないですが、パスワードを毎回入力するのは、面倒です。そこで、セッションを永続化する手段として記憶トークンを生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)によるトークン認証にこの記憶トークンを使います。

  1. 記憶トークンにはランダムは文字列を生成して使う
  2. トークンはハッシュ化してデータベースに保存する
  3. ブラウザのcookiesにトークンを保存するときは有効期限を設定する
  4. ブラウザのcookiesに保存するユーザーIDは暗号化する
  5. ユーザーIDを含むcookiesを受け取ったら、そのIDでユーザーを検索して記憶トークンのcokkiesがデータベースに保存したハッシュ値と一致することを確認(認証)する

保存するデータの文字列をランダムなものに変える

Rubyの標準ライブラリSecureRandomモジュールにあるurlsafe_base64メソッドを使いましょう。
このメソッドは、A~Z a~z 0~9 - _ のいずれかの文字(全部で64通り)から生成される22文字の文字列を返します。

def User.new_token
 SecureRandom.urlsafe_base64
end

2.トークンはハッシュ化してデータベースに保存する

ハッシュ化とは、元のデータから一定の計算手順に従ってハッシュ値と呼ばれる規則性のない固定長の値を求め、その値によって元のデータを置き換えること。

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

BCrypt::Password.create(string, cost: cost)

上のstringはハッシュ化する引数(文字列)、costはコストパラメータと呼ばれる値。

コストパラメータでは、ハッシュを算出するための計算コストを指定する。
コストパラメータの値を高くすれば、ハッシュからオリジナルのパスワードを計算で推測することが困難になりますので、本番環境ではセキュリティ上重要。

cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                              BCrypt::Engine.cost

コストパラメータをテスト中は最小にし、本番環境ではしっかりと計算する方法がわかれば十分。

既にDBにあるremember_digest属性とは別に、remember_tokenをDBに保存せずに、user.remember_tokenメソッド(cookiesの保存場所)を使ってトークンにアクセスできるようにする必要がある。

password属性はhas_secure_passwordメソッドで自動的に作成していた。
そして、仮想的なpassword属性とpassword_digest属性の一致をauthenticateメゾットを使って実装

これと同様に、仮想的なremember_token属性を作る必要がある。

attr_accessor :remember_token

def remember
  self.remember_token = User.new_token
  update_attribute(:remember_digest, User.digest(remember_token))
end
selfを記述することで仮想のremember_token属性にUser.new_tokenで生成した「ハッシュ化されたトークン情報」を代入しています。
seifがないと、単純にremember_tokenというローカル変数が作成されてしまいます。
update_attributeメソッドを使ってトークンダイジェストを更新しています。
update_attributesと違い(よく見ると末尾にsがあるかないかの違いがあります)、こちらはバリデーションを素通りさせます。
今回はユーザーのパスワードなどにアクセス出来ないため、このメソッドを用いてバリデーションを素通りさせる必要がありました。

3.ブラウザのcookiesにトークンを保存するときは有効期限を設定する

cookies[:remember_token] = { value: remember_token,                             
expires: 20.years.from_now.utc }
永続セッションを作成するためには、cookiesというメソッドを使います。
このメソッドはsessionと同様にハッシュとして扱えます。
cookiesは1つのvalue(値)expires(有効期限)からできています。

例えば20年後に期限切れとなる記憶トークンと同じ値をcookiesに保存することで、永続的セッションを作ることができます。20年で期限切れとなるcookiesの設定はよく使用されるようになり、Railsでは専用となるpermanentという特殊なメソッドが追加されました。

cookies.permanent[:remember_token] = remember_token

ユーザーidをcookiesに保存する

cookies[:user_id] = user.id

4.ブラウザのcookiesに保存するユーザーIDは暗号化する

このままではユーザーIDが生の状態で保存されてしまうため安全性が損なわれます。

cookies.signed[:user_id] = user.id

signedメソッドに渡されたcookieは、なりすましを防ぐデジタル署名と、暗号化をまとめて実行される。

さらにユーザーIDと記憶トークンはペアで扱う必要があるのでこちらも永続化しないといけません。

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

例えばページビューでcookiesからユーザーを取り出す場合

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

d:ハッシュの中でcookies.signedとしているのは、署名付きで暗号化されたクッキーのユーザーidを探している為である。(要は、user_idを暗号化して検索してるってこと)。保存する時にsignedメソッドを使ったので、idをサーチする時もsignedメソッドを使う。

5.ユーザーIDを含むcookiesを受け取ったら、そのIDでユーザーを検索して記憶トークンのcokkiesがデータベースに保存したハッシュ値と一致することを確認(認証)する

この一致を確認するにはbcryptを調べる必要があります

この一致をbcryptで確認する為の方法は様々あり、例えばsecure passwordのソースコードには

BCrypt::Password.new(password_digest) == unencrypted_password

これを参考にして

BCrypt::Password.new(remember_digest) == remember_token

しかし、本来であればbcryptのハッシュは復号化できない。
が、bcrypt gemの機能によって、比較に使っている==演算子が再定義されている。


is_password?は論理値メソッドであり、==の代わりに比較として使える。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
def authenticated?(remember_token)     
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

今まで作成したメソッドを各々適用していく

まず、ログインしたuserの暗号化されたidとトークンをしてcookieに保存する

app/helpers/sessions_helper.rb

module SessionsHelper
 def log_in(user)
  session[:user_id] = user.id
end

def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end

def remember(user)  ⇦これを追加
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end

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

def logged_in?
!current_user.nil?
end
end
user.remember について
rememberメソッドは、user.rb で作成したuserモデルに基づくインスタンスメソッドであるが、これはコントローラーで呼び出せます。session.helper.rbで定義したメソッドもapplication_controllerに反映させているので、全てのコントローラーで使用できます。つまり、sessions_controllerでも呼び出せることになります。
また、
update_attribute(:remember_digest, User.digest(remember_token))

self.update_attribute(:remember_digest, User.digest(remember_token))のselfを省略した形

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] = '認証に失敗しました。'
      render :new
    end
  end

また、current_user の設定も追加する必要があります。
current_userは、ログインしているユーザー情報を取得する必要があるため、定義したもの。上記実装により、ブラウザを閉じた後でも、再ログインする必要がなくなったので、session[:user_id]がない。従って、そのユーザーの情報を取得するには、cookieに残っている情報からユーザー情報を取得しなければならない。

if session[:user_id]   
@current_user ||= User.find_by(id: session[:user_id])
elsif cookies.signed[:user_id]
user = User.find_by(id: cookies.signed[:user_id])
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end

これだと、session[:user_id] と signed[:user_id]とで2回dbにアクセスすることになり、重くなる。

そこで、

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
if (user_id = session[:user_id]) は==じゃない点に注意。
変数user_idにsession[:user_id]が存在するなら、代入する。代入できたら、以下実行。できなかったら、つまり、session[:user_id]がないなら、elsifに飛ぶ。

コメント

タイトルとURLをコピーしました