Railsチュートリアル第8章にて、基本的なログイン・ログアウト機能の作成はできましたが、現状だとブラウザを閉じるとセッション情報が失われてしまいます。
(ブラウザを閉じる度にログインが必要になる)
そこで、Cookie(クッキー)を利用してログイン状態を保持できるようにする(Remember me機能を実装する)のが本記事の目的です。
正直、Remember me機能の実装に入ってからは一気に内容が難しくなり、チュートリアルを何回読んでも理解できない箇所があったりしました。
なので、今回は全体の流れを再度確認するために、自分が理解に苦しんだ箇所を中心にまとめてみました。
開発環境
- Ruby 3.1.2
- Ruby on Rails 7.0.3
- M1 Macbook Air 2020
- mac OS Monterey (ver. 12.4)
- ターミナル bash (Rosetta 2 使用)
ソースコード
Remember me 機能の仕上がりイメージ
Remember me 機能が実装された後の、システム上の動作イメージは以下の通りです。
- ユーザーが「Remember me」にチェックを入れてログインする
- 記憶トークン(remember_token)を生成する
- ブラウザのcookiesに記憶トークンと暗号化されたユーザーIDを保存する(有効期限を設定)
- ③と同時に記憶トークンをハッシュ化してDB(remember_digest)に保存する
- ユーザーがブラウザを閉じる(セッション情報が失われる)
- ユーザーが再びブラウザを開き、Webアプリケーションにアクセスする
- Webアプリケーションがcookiesに保存されたユーザーの情報(記憶トークンと暗号化されたユーザーID)を受け取る
- 暗号化されたユーザーIDを復号化してDBから該当するユーザーを検索
- 該当するユーザーの記憶トークン(remember_digest)とcookiesに保存されている記憶トークンが一致するか確認する
- 一致したらsession(セッション)にユーザーIDを保存する(ログインする)
こうすることで、(ブラウザ閉じて)Webアプリケーション側のセッション情報が失われても、
Webブラウザ側に保存したCookieからユーザーIDとトークンを受け取って、セッションを更新してくれる(ログイン状態を保持してくれる)んですね。
(↓こんな感じ。ブラウザ閉じてセッション切れになっても、cookiesにはトークンと暗号化されたユーザーIDが保存されたままになっています)
Cookieは有効期限を設定することも可能で、例えばユーザーのセッションを永続的にするなら、
Cookieへの保存時にpermanentというメソッドを指定することで有効期限を20年にすることができます。
Remember me 機能を実装する具体的な手順
まず最初に、Remember me機能を実装する大まかな流れを以下に示します。
- ハッシュ化した記憶トークンを保存するDBカラム(remember_digest)を追加
- ランダムな記憶トークンを生成する
User.new_token
メソッドを追加 - 渡された文字列をハッシュ化する
User.digest(string)
メソッドを追加 - 仮想の属性
remember_token
を定義 - 記憶トークンをハッシュ化してDBに保存する
remember
メソッドを追加 - ユーザーIDと記憶トークンをcookieに保存する
remember(user)
メソッドを追加(セッションを永続的にする) - 渡された記憶トークンとDBに保存された remember_digest(=ハッシュ化されたトークン)が同じか確認する
authenticated?
メソッドを追加 - current_user を cookie を使った場合にも使えるように変更
- ログアウト時に永続的セッションを破棄する
forget(user)
メソッドを追加 - 「Remember me」のチェックボックスを設置し、機能を実装
こんな感じです。
今回は基本的なユーザー認証機能(ログイン・ログアウト)はすでにできており、それに機能追加する形で進めていきます。
それでは、それぞれの項目について詳しく見ていきましょう。
①ハッシュ化した記憶トークンを保存するDBカラム(remember_digest)を追加
まず最初に、ハッシュ化した記憶トークンを保存するためのダイジェスト(remember_digest)を用意します。
$ rails g migration add_remember_digest_to_users remember_digest:string
マイグレーションファイルを開いて、スペル等間違いがないかチェックします。
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :remember_digest, :string
end
end
問題なければマイグレーションを実行してデータベースのカラムを更新します。
$ rails db:migrate
②ランダムな記憶トークンを生成するUser.new_token
メソッドを追加
今回、ランダムな記憶トークンを生成する方法として、Ruby標準ライブラリのSecureRandom
モジュールにあるurlsafe_base64
メソッドを利用します。
このメソッドは、A–Z、a–z、0–9、”-“、”_”のいずれかの文字(64種類)からなる長さ22のランダムな文字列を返します。
irb(main):001:0> SecureRandom.urlsafe_base64
=> "5bPQ7dfiGkJg5XvF0Mb3iA"
ここで、同一の記憶トークンを持つユーザーが複数いる可能性も考えられますが、
2つの記憶トークンがたまたま完全に一致する(=衝突する)確率は(1/64)22≒(1/10)40となるので、万が一にもトークン同士が衝突することはないでしょう。
((1/10)8でも1億分の1の確率。(1/10)40のすごさについては数字の単位を参照)
これを、User.new_token
メソッドとして、user.rb
に以下のように定義します。
・・・
# ランダムな記憶トークンを返す
def User.new_token
SecureRandom.urlsafe_base64
end
・・・
end
User.new_token
メソッドが使えることを確認してみましょう。
irb(main):002:0> User.new_token
=> "mq1F5IQbra-Dq3K27j4RYA"
③渡された文字列をハッシュ化するUser.digest(文字列)
メソッドを追加
User.new_tokenによって生成された記憶トークンをダイジェストに変換してデータベースに保存するようにするために、渡された文字列をハッシュ化するUser.digest(文字列)
メソッドを追加します。
・・・
# 渡された文字列のハッシュ値を返す
def User.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
・・・
end
上のstring
はハッシュ化する文字列、cost
はコストパラメータとよばれる値です。
コストパラメータの値を高くすれば、ハッシュ化したトークンからオリジナルのトークンを計算で推測することが困難になります。
なお、上記のコストパラメータは、テスト中はコストパラメータを最小にし、本番環境ではしっかりと計算するようになっています。
追加したUser.digestメソッドによって、生成されたトークンがハッシュ化されていることを確認します。
irb(main):003:0> User.digest(User.new_token)
=> "$2a$12$Or.6X4v.8io5Iq.fOVK9ueWkgukEo0rCB9EJCH.aLg79NQJHregMi"
④仮想の属性remember_token
を定義
この後にrememberメソッドを作成するため、ユーザーモデルに仮想の属性であるremember_token
属性を定義します。
(ユーザーモデルにremember_tokenカラムを追加しないのは、ハッシュ化する前の生の記憶トークンをDBに保存させたくないからです)
このremember_token
属性を定義することで、user.remember_token
メソッドを使ってトークンにアクセスできるようになります。
仮想の属性は以下のようにattr_accessor
を用います。(attr は attribute⦅属性⦆の略)
class User < ApplicationRecord
attr_accessor :remember_token
・・・
end
attr_accessor :remember_token
とすることで、クラス外部からインスタンス変数(@user)のremember_token属性にアクセスしてデータの読み書きをすることができるようになります。(↓こんな感じに)
irb(main):004:0> user.remember_token
=> "AmJu3mZteC3dlFYmo2NtOA"
⑤記憶トークンをハッシュ化してDBに保存するremember
メソッドを追加
user.remember
とすることで、userの記憶トークンをハッシュ化してDBに保存するremember
メソッドを作成します。
・・・
def User.new_token
SecureRandom.urlsafe_base64
end
def User.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
# 上記2つのメソッドを利用し、生成された記憶トークンをハッシュ化
# ハッシュ化した記憶トークンをDB(remember_digest)に保存する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
・・・
end
上のself.remember_token = User.new_token
で、生成した記憶トークンをremember_token
属性に代入しています。
ここで、self.remember_token
としている理由ですが、
selfを無くしてremember_tokenとしてしまうと、Ruby上ではremember_tokenは属性ではなくてただのローカル変数扱いとなるので、クラス外部から@user.remember_tokenとしてもアクセスできなくなってしまいます。
上のrememberメソッドを実行(@user.remember)することで、以下の処理を行います。
- self.remember_tokenのselfが@userに置き換わり、
@user.remember_token
に生成した記憶トークンを代入 remember_token
属性に代入された記憶トークンをUser.digest
でハッシュ化し、データベースのremember_digestカラムに保存
実際にirb上で試してみましょう。
# ①ユーザー情報を user に代入
irb(main):001:0> user = User.last
...
=>
#<User:0x00000001148620d0
...
# ②user の remember_token, remember_digest にアクセス(この時点ではまだ空)
irb(main):002:0> user.remember_token
=> nil
irb(main):003:0> user.remember_digest
=> nil
# ③remember メソッドを実行
irb(main):004:0> user.remember
...
=> true
# ④生成された記憶トークンの存在と、ハッシュ化された記憶トークンのDBへの保存を確認
irb(main):005:0> user.remember_token
=> "Iu6OdB96qr2bmznhhn3A1Q"
irb(main):006:0> user.remember_digest
=> "$2a$12$oZoAeG/OgshwO.tkNQKe0uycACGdJAVLYaWiGJ2tOfclPQsZn2z9a"
rememberメソッドを実行すると、remember_tokenには生成された生の記憶トークンが、remember_digestにはハッシュ化された記憶トークンが保存されていることがわかります。
⑥ユーザーIDと記憶トークンをcookieに保存するremember(user)
メソッドを追加(セッションを永続的にする)
記憶トークンを生成、ハッシュ化して保存できる仕組みが完成したら、次は生の記憶トークンと暗号化したユーザーIDをcookie(クッキー)に保存するメソッドremember(user)を作成します。
cookieに値を保存する際は有効期限を設定する必要があります(設定しないとブラウザ閉じた瞬間に有効期限が切れる)が、今回はセッションを永続的にするためにpermanentメソッドを追加します。
(cookies.permanentでは、有効期限は20年に設定される)
・・・
# ユーザーのセッションをcookieに保存する(永続的セッション)
def remember(user)
user.remember
cookies.permanent.encrypted[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
・・・
end
上のremember(@user)メソッドでは、
- @user.rememberで@userの記憶トークンを生成し、ハッシュ化した記憶トークンをDBに保存する
- @userのユーザーIDを暗号化(encrypted)してブラウザの永続cookiesに保存
- @userの記憶トークンをブラウザの永続cookiesに保存
の処理を行なっています。
もし、cookieの有効期限を任意の期間(例えば1ヶ月)に指定したい場合は以下のように書きます。
・・・
# cookiesに保存されたセッションに有効期限を設けたい場合(1ヶ月)
def remember(user)
user.remember
# cookies の有効期限を 1ヶ月 にしたい場合
cookies.encrypted[:user_id] = { value: user.id, expires: 1.month.from_now }
cookies[:remember_token] = { value: user.remember_token, expires: 1.month.from_now }
end
・・・
end
⑦渡された記憶トークンとDBに保存された remember_digest(=ハッシュ化されたトークン)が同じか確認するauthenticated?
メソッドを追加
続いては、ブラウザの永続cookiesに保存した記憶トークンとDBにハッシュ化して保存された記憶トークンが同じかどうかを認証するメソッドを作成します。
・・・
# cookiesから渡されたトークンがダイジェスト(ハッシュ化されたトークン)と一致したらtrueを返す
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
・・・
end
このコード中の
BCrypt::Password.new(remember_digest).is_password?(remember_token)
の部分で、cookiesのトークンとDBのトークンの認証を行い、一致すればtrue
を返します。
irb(main):021:0> remember_token = user.remember_token
=> "Iu6OdB96qr2bmznhhn3A1Q"
irb(main):022:0> remember_digest = user.remember_digest
=> "$2a$12$oZoAeG/OgshwO.tkNQKe0uycACGdJAVLYaWiGJ2tOfclPQsZn2z9a"
# cookiesのトークン(remember_token)とDBのハッシュ化されたトークン(remember_digest)の認証
# 一致したらtrueを返す
irb(main):023:0> BCrypt::Password.new(remember_digest).is_password?(remember_tok
en)
=> true
ここで、コード上でreturn false if remember_digest.nil?
としているのは、もしremember_digestが空なのにブラウザのcookiesに記憶トークンが残ったままになっていると、認証時にエラー(バグ)が出てしまうためです。
例えば、
- Google Chrome でログイン
- Google Chrome を閉じる(cookieにユーザーIDとトークンは保存されたまま)
- 別のブラウザ( Firefox)でもログイン、しばらくしてログアウト(永続的cookieを破棄)
- 再度 Google Chrome を開く
このような操作をした場合に認証エラー(バグ)が発生します。
Google Chromeのcookiesにはまだ記憶トークンが残ったままになっているので、認証時に比較相手(remember_digest)がいないとエラーになってしまいます。
そこで、remember_digestが空(すでに別ブラウザでログアウト済み)の場合は強制的にfalseを返すことでエラーを防ぐことができます。
また、以下のようにログイン中にのみログアウトできるようにしておくことで、上記の問題は解決できます。
・・・
def destroy
# ログイン中のみログアウトする
log_out if logged_in?
redirect_to root_path, status: :see_other
end
end
⑧current_user を cookie を使った場合にも使えるように変更
現在ログインしているユーザー(@current_user)の更新を、cookieを使った場合でもできるようにコードを変更します。
・・・
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
# ↓↓↓↓↓ 以下のようにコードを変更
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.encrypted[: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
・・・
end
上のコードでは以下のような処理を行なっています。
- session[:user_id]をuser_idに代入した結果、値が存在する(セッションが有効)ならば
@current_user ||= User.find_by(id: user_id)
で現在のユーザー情報を更新 - session[:user_id]をuser_idに代入した結果、値が存在しない(セッション切れ)ならばelsifに進む
- cookies.encrypted[:user_id]をuser_idに代入した結果、値が存在する(cookieが有効)ならば、ローカル変数userにuser_idに該当するユーザー情報を代入する
- DBに該当ユーザーが存在する、且つ authenticate?メソッドでcookiesのトークンとDBに保存済みのハッシュ化されたトークンが一致したら
- ログインし、
@current_user = user
で現在のユーザーを更新 - cookies.encrypted[:user_id]をuser_idに代入した結果、値が存在しない(cookieが無効)ならば、何もせず終了(@current_userは更新されない)
少しごちゃごちゃしてわかりにくいかもですが、簡単にいうと
Webアプリ側のセッションが切れてたら、ブラウザのcookiesに保存されているトークンとユーザーIDを使って認証しますよ。
認証に成功したら、Webアプリケーションにログインして、@current_user = user で現在ログインしているユーザーを更新しますよ。
認証に失敗したら、何もしませんよ(@current_userは更新されませんよ)
という流れです。
⑨ログアウト時に永続的セッションを破棄するforget(user)
メソッドを追加
cookieに記憶トークンと暗号化されたユーザーIDを保存した場合、cookieの有効期限が来るまでユーザー側が明示的に破棄しない限りブラウザに残り続けます。
そこで、ログアウト時に永続的セッションを破棄するメソッドforget(user)
を作成します。
まずは、ユーザーのDBに保存された記憶ダイジェスト(remember_digest)を破棄するforget
メソッドをurer.rb
に追加します。
・・・
# ユーザーの記憶ダイジェストを破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
続いて、forget
メソッドを用いて永続的セッションを破棄するメソッドforget(user)
を作成すると共に、あらかじめ作成してあったlog_outメソッドにforget(user)
メソッドを追記します。
(log_outでは現在のユーザーのセッション情報を破棄したいので、forget(user)
のuserには、current_user
が入ります)
・・・
# 永続的セッションを破棄する
def forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
# ログアウトする(セッション情報を削除する)
def log_out
# ログアウト時に current_user の永続的セッションも破棄する
forget(current_user)
session.delete(:user_id)
@current_user = nil
end
end
これで、ログアウト時にcookiesに保存されていた記憶トークンと暗号化されたユーザーIDが削除されます。
⑩「Remember me」のチェックボックスを設置し、機能を実装
最後に、「Remember me」のチェックボックスを設置し、機能を実装していきます。
まずは、ログインフォームにチェックボックス用のフォームを設置します。
<div class="field">
<div class="ui checkbox">
<%= form.check_box :remember_me %>
<%= form.label "ログイン状態を保持する" %>
</div>
</div>
:remember_me
にアクセスできるよう、user.rb
に:remember_me
属性を追記します。
class User < ApplicationRecord
attr_accessor :remember_token, :remember_me
・・・
end
続いては、「Remember me」のチェックの有無によってcookieに値を保存するかしないかを条件分岐するコードを書いてきます。
「Remember me」にチェックするとparams[:session][:remember_me]
に “1”が、チェックしなかった場合は“0”が、それぞれ代入されます。
この値を使うことで、cookieに値を保存するかしないかを以下のように表現できます。
params[:session][:remember_me] == "1" ? remember(user) : forget(user)
三項演算子で、params[:session][:remember_me]
が”1″ならremember(user)メソッドでcookieに値を保存し、それ以外ならforget(user)メソッドでcookieに値を保存しないようにしています。
このコードをsessions_controller.rb
に追加すれば「Remember me」機能の実装は完了です。
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in(user)
# remember_me にチェックしたらユーザーのセッションを永続的にする(cookieに保存する)
params[:session][:remember_me] == "1" ? remember(user) : forget(user)
flash[:notice] = "ログインに成功しました"
redirect_to user
else
flash.now[:error] = "ログインに失敗しました"
render "new", status: :unprocessable_entity
end
end
def destroy
log_out if logged_in?
redirect_to root_path, status: :see_other
end
end
実際に「Remember me」機能がちゃんと動いているかどうかを確認して見ましょう。
確認方法は、ブラウザのcookies情報を見ればわかります。
このように、Remember me(ログイン情報を保持する)ボタンにチェックしている場合にのみ、cookieに情報(記憶トークンと暗号化されたユーザーID)が保存されていることがわかります。
以上です。
コメント