【Rails7】Cookieを使ってログイン状態を保持する(Remember me 機能の実装)

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 使用)

ソースコード

GitHub
GitHub - hirokirokki0820/user-authentication Contribute to hirokirokki0820/user-authentication development by creating an account on GitHub.

Remember me 機能の仕上がりイメージ

Remember me 機能が実装された後の、システム上の動作イメージは以下の通りです。

  1. ユーザーが「Remember me」にチェックを入れてログインする
  2. 記憶トークン(remember_token)を生成する
  3. ブラウザのcookiesに記憶トークンと暗号化されたユーザーIDを保存する(有効期限を設定)
  4. ③と同時に記憶トークンをハッシュ化してDB(remember_digest)に保存する
  5. ユーザーがブラウザを閉じる(セッション情報が失われる)
  6. ユーザーが再びブラウザを開き、Webアプリケーションにアクセスする
  7. Webアプリケーションがcookiesに保存されたユーザーの情報(記憶トークンと暗号化されたユーザーID)を受け取る
  8. 暗号化されたユーザーIDを復号化してDBから該当するユーザーを検索
  9. 該当するユーザーの記憶トークン(remember_digest)とcookiesに保存されている記憶トークンが一致するか確認する
  10. 一致したらsession(セッション)にユーザーIDを保存する(ログインする)

こうすることで、(ブラウザ閉じて)Webアプリケーション側のセッション情報が失われても、

Webブラウザ側に保存したCookieからユーザーIDとトークンを受け取って、セッションを更新してくれる(ログイン状態を保持してくれる)んですね。

(↓こんな感じ。ブラウザ閉じてセッション切れになっても、cookiesにはトークンと暗号化されたユーザーIDが保存されたままになっています)

Cookieは有効期限を設定することも可能で、例えばユーザーのセッションを永続的にするなら、

Cookieへの保存時にpermanentというメソッドを指定することで有効期限を20年にすることができます。

Remember me 機能を実装する具体的な手順

まず最初に、Remember me機能を実装する大まかな流れを以下に示します。

  1. ハッシュ化した記憶トークンを保存するDBカラム(remember_digest)を追加
  2. ランダムな記憶トークンを生成するUser.new_token メソッドを追加
  3. 渡された文字列をハッシュ化するUser.digest(string) メソッドを追加
  4. 仮想の属性remember_tokenを定義
  5. 記憶トークンをハッシュ化してDBに保存するrememberメソッドを追加
  6. ユーザーIDと記憶トークンをcookieに保存するremember(user)メソッドを追加(セッションを永続的にする)
  7. 渡された記憶トークンとDBに保存された remember_digest(=ハッシュ化されたトークン)が同じか確認するauthenticated?メソッドを追加
  8. current_user を cookie を使った場合にも使えるように変更
  9. ログアウト時に永続的セッションを破棄するforget(user)メソッドを追加
  10. 「Remember me」のチェックボックスを設置し、機能を実装

こんな感じです。

今回は基本的なユーザー認証機能(ログイン・ログアウト)はすでにできており、それに機能追加する形で進めていきます。

あわせて読みたい
【Rails7】ユーザー認証機能(ログイン・ログアウト)を実装するまでの一連の流れ Railsでサービスを開発しようとなると避けて通れない(と思う)のが、ユーザーの認証機能。 認証機能を1から作るのはかなり大変ですが、幸いRailsにはDeviceという簡単...

それでは、それぞれの項目について詳しく見ていきましょう。

①ハッシュ化した記憶トークンを保存する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)することで、以下の処理を行います。

  1. self.remember_tokenのselfが@userに置き換わり、@user.remember_tokenに生成した記憶トークンを代入
  2. 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)メソッドでは、

  1. @user.rememberで@userの記憶トークンを生成し、ハッシュ化した記憶トークンをDBに保存する
  2. @userのユーザーIDを暗号化(encrypted)してブラウザの永続cookiesに保存
  3. @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に記憶トークンが残ったままになっていると、認証時にエラー(バグ)が出てしまうためです。

例えば、

  1. Google Chrome でログイン
  2. Google Chrome を閉じる(cookieにユーザーIDとトークンは保存されたまま)
  3. 別のブラウザ( Firefox)でもログイン、しばらくしてログアウト(永続的cookieを破棄)
  4. 再度 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

上のコードでは以下のような処理を行なっています。

  1. session[:user_id]をuser_idに代入した結果、値が存在する(セッションが有効)ならば@current_user ||= User.find_by(id: user_id)で現在のユーザー情報を更新
  2. session[:user_id]をuser_idに代入した結果、値が存在しない(セッション切れ)ならばelsifに進む
  3. cookies.encrypted[:user_id]をuser_idに代入した結果、値が存在する(cookieが有効)ならば、ローカル変数userにuser_idに該当するユーザー情報を代入する
  4. DBに該当ユーザーが存在する、且つ authenticate?メソッドでcookiesのトークンとDBに保存済みのハッシュ化されたトークンが一致したら
  5. ログインし、@current_user = user で現在のユーザーを更新
  6. 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)が保存されていることがわかります。

以上です。

【続き】ユーザー新規登録時にアカウントを有効化する(メール認証)

あわせて読みたい
【Rails7】ユーザー新規登録時にアカウントを有効化する(メール認証) Railsチュートリアル第11章にて、メール認証によるアカウントの有効化について学んだので、その復習とメモ用に本記事を残そうと思います。 ここでは、すでにログイン機...

参考記事

第9章 発展的なログイン機構 - Rai...
第9章 発展的なログイン機構 - Railsチュートリアル SNS開発やWebサイト制作が学べる大型チュートリアル。作りながら学ぶのが特徴で、電子書籍や解説動画、質問対応、社員研修、教材利用にも対応しています。
Qiita
[Railsチュートリアル] 9章の内容を自分なりに落とし込む - Qiita 何回読んでも「あれ、今何してるんだっけ?」と分からなくなるので特に頭がごちゃごちゃになる、rememberメソッドを作るまでの流れを自分なりにざっくりまとめてみました。...
Enjoy IT Life
RailsでセッションとCookieを操作する方法 | Enjoy IT Life セッションとはステートレスなHTTP通信においてステートフルを実現するための情報、Cookieとはブラウザに用...
Pikawaka
attr_accessorメソッドの使い方と必要な理由とは? attr_accessorとは、インスタンス変数の読み取り専用のメソッドと書き込み専用のメソッド(セッター/ゲッター)の両方を定義することが出来るメソッドのことです。attr_acces...
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

愛知の34歳。無職で暇になり始めたプログラミング(Ruby on Rails)の忘備録をまとめたブログです。最近は別にやりたいことができたのでプログラミングほぼやっていません。気が向いたらまた再開するかも。僕の日常はメインブログの方で更新しています。

コメント

コメントする

目次