ログイン機能(2)までで、session
メソッドを使ってユーザーIDを一時的セッションに保存しました。これは、ブラウザを閉じると破棄されてしまいます。これでも、問題はないですが、パスワードを毎回入力するのは、面倒です。そこで、セッションを永続化する手段として記憶トークンを生成し、cookies
メソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)によるトークン認証にこの記憶トークンを使います。
- 記憶トークンにはランダムは文字列を生成して使う
- トークンはハッシュ化してデータベースに保存する
- ブラウザのcookiesにトークンを保存するときは有効期限を設定する
- ブラウザのcookiesに保存するユーザーIDは暗号化する
- ユーザーIDを含むcookiesを受け取ったら、そのIDでユーザーを検索して記憶トークンのcokkiesがデータベースに保存したハッシュ値と一致することを確認(認証)する
1.記憶トークンの生成
トークンを記憶するカラムをdbに追加する
$ rails g migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
記憶ダイジェストはユーザーが直接読み出すことはないので、remember_digest
カラムにインデックスを追加する必要はない。
保存するデータの文字列をランダムなものに変える
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に飛ぶ。
コメント