TODOアプリに、自動ログイン機能を追加します。
以前の記事で、sessionメソッドを使ってユーザーIDを保存しましたが、この情報はブラウザを閉じると消えてしまいます。
毎回ログインし直すのは少し不便なので、ログインフォームの「Remember me」チェックボックスにチェックをしてログインした場合には、再訪時に自動でログインできるように修正していきましょう。
##処理の流れ
-
「Remember me」にチェックを入れてログインした時、記憶トークン(ランダムな文字列)と記憶ダイジェスト(記憶トークンをハッシュ化したもの)を作成
-
記憶トークンと暗号化したユーザーIDをcookieに保存し(有効期限20年)、記憶ダイジェストをDBに保存する
-
Sessonの無いユーザーがサイトを訪問した時、cookieにユーザーIDがあれば、そのIDをキーにDBを検索
-
検索して取得したユーザーの記憶ダイジェストと、cookieの記憶トークン(をハッシュ化したもの)を比較
-
一致すればSessionを開始する
-
(明示的な)ログアウト時にcookieのユーザーID、記憶トークン、DBの記憶ダイジェストを全て削除する
※トークン:コンピューターによって作成、管理されるパスワードのこと
それでは、以下よりシステムを修正していきます。
##Modelの修正
###Userモデルにremember_digest
属性を追加
$ rails g migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
マイグレーション名の末尾を_to_users
にすることで、マイグレーションの対象がデータベースのusersテーブルであることをRailsに指示しています。
###remember_token
属性を有効化
remember_token
属性はDBには存在しない属性です。attr_accessor
を使って、仮想的に利用できるようにします。
class User < ActiveRecord::Base
attr_accessor :remember_token
.
.
##Viewにチェックボックスを追加
フォームの後半に以下の記述を追加し、「Remember me」チェックボックスを表示します。
(後ほど、チェックが入っていた時にだけセッションを永続化する処理を実装していきます)
<%= f.label :remember_me do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
行全体をラベルで囲んでいるので、行のどこの部分でもクリックできるようになっています。
##ログイン用メソッドを作成
Controllerでログイン永続化の処理を簡単に記述できるように、ログインに使う各種メソッドを作成していきます。
###トークン生成用メソッド
長くてランダムな文字列が作れれば方法は何でも構いません。ここではRuby標準ライブラリのSecureRandomモジュール
にあるurlsafe_base64メソッド
を利用します。
# ランダムなトークンを返す
def self.new_token #User.new_tokenと同じ意味
SecureRandom.urlsafe_base64
end
###ハッシュ化用メソッド
記憶トークンをハッシュ化して記憶ダイジェストを作成するために、ハッシュ化用のメソッドを用意します。
# 与えられた文字列のハッシュ値を返す
def self.digest(string) #User.digest(string)を同じ意味
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
###記憶トークンと記憶ダイジェストを生成するメソッド
上記で作成した2つのメソッドを使って、
・記憶トークンの生成→ユーザーに紐付け
・記憶ダイジェストの生成→DBに登録
の2つを行うメソッドを作成します。
# 永続的セッションで使用するユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
###記憶トークンと記憶ダイジェストを比較するメソッド
パスワードの認証の時には、authenticate
メソッド(has_secure_passwordと記入するだけで作られるメソッド)を利用して、受け取ったパスワードとDBのパスワードダイジェストを比較しました。
今回は、比較用のメソッドを自分で作る必要があります。
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
is_password?
を使って比較しているのは、BCrypt
のソースコードを読み解いた結果このメソッドが使えることがわかったからです。
###ユーザーID、記憶トークン、記憶ダイジェストを登録するヘルパーメソッド
暗号化したユーザーIDと、記憶トークンをcookieに、記憶ダイジェストをDBに登録するヘルパーメソッドを作成します。
今回の実装の要となるメソッドです。
ここまでのステップは、全てこのメソッドを作成するためにあったと言っても過言ではありません。
# ユーザーのセッションを永続的にする
def remember(user)
user.remember #Userモデルで定義したrememberメソッド。記憶トークンを作成、ハッシュ化してDBに保存
cookies.permanent.signed[:user_id] = user.id #ユーザーIDを暗号化してcookieに保存
cookies.permanent[:remember_token] = user.remember_token #記憶トークンをcookieに保存
end
permanent
を使ってcookieを永続化し(有効期限を20年に設定)、
signed
を使ってユーザーIDを暗号化しています。
取り出しの際にはid = cookies.signed[:user_id]
などで取り出すことができます。
##ログイン処理を修正
###ログイン後にremember
メソッドを実行
作成したremember
メソッド(ヘルパーメソッドの方)をログイン後に実行するよう、Createアクションを修正します。
チェックボックスにチェックがなかった場合に実行するforget
メソッドは、後ほど実装します。
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in user
#remember_meにチェックが入って入れば記憶トークン等を登録、チェックが無ければ記憶トークン等を削除
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
redirect_to user
else
.
.
###ログイン中のユーザーを取得するメソッドを修正
ログイン中のユーザーを取得するcurrent_user
メソッドを、上から下のように修正します。
セッションがなくてもすぐにあきらめず、cookieのユーザーIDと記憶ダイジェストを使ってユーザーを取得するようになりました。
修正前
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
修正後
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
ユーザーIDと記憶ダイジェストを使ってユーザーを取得した後には、log_in
メソッドでセッションも開始しています。
##ログアウト用メソッドを作成
###記憶ダイジェストをnilで更新するメソッド
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
###ユーザーID、記憶トークン、記憶ダイジェストを全て削除するメソッドを作成
作成したforget
メソッドを使って、セッション永続化に関わる全ての値を削除するヘルパーメソッドを作ります。
# 永続的セッションを破棄する
def forget(user)
user.forget #Userクラスのforgetメソッド。DBの記憶ダイジェストにnilを登録。
cookies.delete(:user_id) #cookieのユーザーIDを削除
cookies.delete(:remember_token) #cookieの記憶トークンを削除
end
##ログアウト処理を修正
###ログアウト用メソッドを修正
作成したヘルパーメソッドforget
を、ログアウト用ヘルパーメソッドlog_out
から呼び出します。
# 現在のユーザーをログアウトする
def log_out
forget(current_user) #この行を追加
session.delete(:user_id)
@current_user = nil
end
##細かなバグを修正
現状、2つの細かいバグが存在するので、修正していきます。
###バグ1
1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーが発生する
→1度ログアウトすると(log_out
メソッドを実行すると)、current_user
の値がnil
になるので、2回目のlog_out
メソッドが失敗する。
→ログアウト前に、ログイン状態を確認するように修正する。
class SessionsController < ApplicationController
.
.
def destroy
log_out if logged_in? #この行を修正
redirect_to root_url
end
end
###バグ2
Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、再度Chromeで同じページを開くとエラーが発生する
→片方のブラウザでログアウトしても、もう片方のブラウザにはcookie(ユーザーIDと記憶トークン)が残り続ける
→DBの記憶ダイジェストは削除されているのに、記憶トークンだけが残っている
→記憶トークンと記憶ダイジェストの比較の際にエラーが発生
→比較の前に、記憶ダイジェストの有無をチェックする
class User < ActiveRecord::Base
.
.
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
return false if remember_digest.nil? #この行を追加
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end