##永続ログインとcookies
一時的なログインの維持にはsessionメソッドを使用する。
しかし、ブラウザを閉じると自動的にログアウトしてしまう。
ログイン状態を維持するためには、cookiesメソッドと、記憶トークンと呼ばれるデータを使用する。
記憶トークンはユーザーごとに固有な、パスワードのようなものである。
具体的な手順はこうなる。
①ユーザーごとに記憶トークンを作成する。
②記憶トークンをハッシュ化してUserモデルのカラム(remember_digest)に保存する。
③cookiesに記憶トークンとユーザーIDを入れる。
④ユーザーIDが入ったcookiesを受け取ったら、そのIDをもとにデータベースからユーザーを探し出し、そのremember_digestカラムに保存されているハッシュ値と、cookiesに入っているトークンをハッシュ化した値が一致するか確認する。
##記憶トークンの生成と保存
###remember_digestカラム
マイグレーションファイルを作成して、Userモデルにremember_digestカラムを追加する。
このカラムには、記憶トークンをハッシュ化して保存する。
$ rails generate migration add_remember_digest_to_users remember_digest:string
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :remember_digest, :string
end
end
###記憶トークンの生成
記憶トークンはどんな文字列でもよいが、パスワードと違って個人が覚えておく必要がなく、かつセキュリティ上ランダムな文字列であることが望ましい。
ランダムな文字列を生成するために、ここではSecureRandomモジュールにあるurlsafe_base64メソッドを使う。
$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"
これを使って、トークンを生成するメソッドをUserモデルに定義する。
# 渡された文字列のハッシュ値を返す
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を使う。
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を入れる。
cookies[:user_id] = user.id
cookies[:remember_token] = user.remember_token
ここで、cookiesを永続化するために.permanentメソッドを使用する。
また、ユーザーIDはセキュリティ上の問題から.signedメソッドを使用して暗号化する。
これは署名つきcookieと呼ばれる。
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
署名つきcookieは自動で複合化されるので、そのままユーザー検索に使える。
User.find_by(id: cookies.signed[:user_id])
これをSessionsコントローラのログイン部分で使用するために、Sessionsコントローラのヘルパーメソッドとして定義する。
# 渡されたユーザーでログインする
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メソッドは次のように定義されている。
BCrypt::Password.new(password_digest)is_password?(unencrypted_password)
左の()内にあるのはpassword_digestカラムに入っているハッシュ化されたパスワードである。
右の()内にあるのは生のパスワードである。
これをトークンに当てはめるとこうなる。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
これをもとに、authenticated?メソッドをUserモデルに定義する。
# 渡されたトークンがダイジェストと一致したら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ヘルパーメソッドを使用する。
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からユーザーを探すように修正する。
# 記憶トークン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に入れてから使っている。
##永続セッションからのログアウト
永続ログイン状態を解除するために、永続ログインで作った2つのrememberメソッドにそれぞれ対応し、逆の操作をする2つのforgetメソッドを定義して、ログアウト処理に組み込む。
###forgetインスタンスメソッド
Userモデルのrememberメソッドでは、Userオブジェクトのremember_digest属性にトークンをハッシュ化して保存した。
forgetメソッドはその逆で、remember_digest属性を空にする。
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
###forgetヘルパーメソッド
Sessionsヘルパーのremember(user)メソッドでは、rememberインスタンスメソッドを呼び出した後、cookiesにユーザーIDとトークンを入れた。
forget(user)メソッドはその逆である。
# 永続的セッションを破棄する
def forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
###log_outメソッドの修正
最後に、log_outメソッドにforget(user)メソッドを追加すればログアウト処理が完成する。
# 現在のユーザーをログアウトする
def log_out
forget current_user
session.delete(:user_id)
@current_user = nil
end
##2つのバグ
完成した永続ログイン機能は、小さなバグが2つあるらしい。
…が、今のところ重要ではなさそうなので割愛する。
##永続ログインのテスト
テスト部分は別記事にまとめることにする。