認証機能を提供するGem「devise」のrememberableのソースコードを追ってみた

こんにちは!kossyです!




今回は認証機能を提供するGem「devise」のrememberableのソースコードを追ってみたので、
備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.6
Rails 6.0.3
devise 4.8.0



github.com





なお、説明の前提として、deviseを利用しているUserモデルが定義されていることとします。





remeberableとは?

Webサイトにログインする時に、「ログイン状態を保持する」というチェックボックスを見たことがある方も多いと思いますが、

deviseのremeberableを使うと、上記の機能を簡単に実装することができます。

その仕組みを、ソースコードを追って理解するのが本記事の狙いです。

rememberableの機能を使うための準備

remeberableの機能を有効にするには、deviseを利用するモデルに、「rememberable」モジュールを追加するのと、remember_created_atカラム を追加することです。

class User < ActiveRecord::Base
    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable
end
class DeviseUsers < ActiveRecord::Migration[6.0]
  def change
    create_table(:users) do |t|
      # 省略

      ## Rememberable
      t.datetime :remember_created_at

      # 省略
    end
  end
end

これで準備完了です。



ソースコードを追う

まずはコメントアウト部分を訳してみます。

Rememberable manages generating and clearing token for remembering the userfrom a saved cookie.
Rememberable also has utility methods for dealing with serializing the user into the cookie and back from the cookie,
trying to lookup the record based on the saved information.
You probably wouldn't use rememberable methods directly, they are used mostly internally for handling the remember token.

Rememberableは、保存されたCookieからユーザーを記憶するためのトークンの生成とクリアを管理します。
Rememberableには、ユーザーをCookieにシリアル化し、Cookieから戻すためのユーティリティメソッドもあります。
保存された情報に基づいてレコードを検索しようとしています。
おそらく、rememberableに定義されたメソッドを直接使用することはないでしょう。
それらは、ほとんどの場合、記憶トークンを処理するために内部的に使用されます。

出典: https://github.com/heartcombo/devise/blob/master/lib/devise/models/rememberable.rb#L9

なるほど、開発者自らrememberableのメソッドを呼び出すことは稀だそうです。

そうなるとより内部の実装に興味が湧いてきました。開発者の性でしょうか。



Options以下の文章も訳してみます。

Rememberable adds the following options in devise_for:
* +remember_for+: the time you want the user will be remembered without asking for credentials.
After this time the user will be blocked and will have to enter their credentials again.
This configuration is also used to calculate the expires time for the cookie created to remember the user.
By default remember_for is 2.weeks.
* +extend_remember_period+: if true, extends the user's remember period when remembered via cookie. False by default.
* +rememberable_options+: configuration options passed to the created cookie.

Rememberableは、devise_forに次のオプションを追加します。
* + Remember_for +:クレデンシャルを要求せずにユーザーに記憶させたい時刻。
この時間の後、ユーザーはブロックされ、資格情報を再度入力する必要があります。
この構成は、ユーザーを記憶するために作成されたCookieの有効期限を計算するためにも使用されます。
デフォルトでは、remember_forは2週間です。
* + extends_remember_period +:trueの場合、Cookieを介して記憶されるときにユーザーの記憶期間を延長します。 デフォルトではFalseです。
* + Rememberable_options +:作成されたCookieに渡される構成オプション。

出典: https://github.com/heartcombo/devise/blob/master/lib/devise/models/rememberable.rb#L16

Userモデルに上記3つのメソッドが追加されているようなので、コンソールで試してみます。

remember_for

$ rails c

$ User.remember_for
=> 2 weeks

$ User.remember_for.class
=> ActiveSupport::Duration

デフォルトで2週間とのことだったので、 2 weeks が返り値になっていました。クラスはActiveSupport::Durationクラスのインスタンスです。

remeber_forは、config/iniailizers/devise.rbで値を変更することができます。

# config/initializers/devise.rb

  # ==> Configuration for :rememberable
  # The time the user will be remembered without asking for credentials again. (訳: 認証情報を再度要求せずにユーザーが記憶される時間)
  config.remember_for = 1.days # コメントアウトを外して、1 daysに変更

この状態でrails cを再度実行してremember_forメソッドを実行してみます。

$ rails c

$ User.remember_for
=> 1 day

devise.rbで設定した値が正しく反映されています。

ついでにremember_forの定義元を確認してみます。

$ User.method(:remember_for).source_location
=> ["/usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb", 37]

ソースコードはこちら。devise/models.rb at master · heartcombo/devise · GitHub

class_evalを使って、config/initializers/devise.rbの内容を元に動的にメソッドを定義するメソッドでした。


extends_remember_period

こちらもコンソールで試してみます。

# extends_...の方は定義されてないらしい。

$ User.extends_remember_period
NoMethodError: undefined method `extends_remember_period' for User

$ User.extend_remember_period
=> false

ソースコードはこちら。devise/rememberable.rb at master · heartcombo/devise · GitHub

こちらもconfigファイルから設定値を変更することができます。

# config/initializers/devise.rb

  # If true, extends the user's remember period when remembered via cookie.(訳: trueの場合、Cookieを介して記憶されるときにユーザーの記憶期間を延長します)
  config.extend_remember_period = true
$ User.extend_remember_period
=> true

Rememberable_options

config/initializers/devise.rbのコメントアウトを読むと、

Options to be passed to the created cookie.
For instance, you can set
secure: true in order to force SSL only cookies.

作成されたCookieに渡されるオプション。
たとえば、

secure: true

SSL/TLS通信時にのみcookieを送信するためのオプション

$ User.rememberable_options
=> {}

# secure: true(ついでにhttponly: trueも)を追加した後

$ User.rememberable_options
=> {:secure=>true, :httponly=>true}

httponlyは、JavaScriptからのcookie操作をできなくするオプションです。クロスサイトスクリプティング (XSS) 攻撃を緩和するのに役立ちますので、
JavaScriptからcookieを操作したいニーズがない場合は迷わず付与すべきでしょう。

remember_me!

ソースコードはこちら。devise/rememberable.rb at master · heartcombo/devise · GitHub

「認証状態を保持する」にチェックが入った状態でログイン処理が行われた場合に呼び出されるメソッドです。

      def remember_me!
        self.remember_token ||= self.class.remember_token if respond_to?(:remember_token)
        self.remember_created_at ||= Time.now.utc
        save(validate: false) if self.changed?
      end

remember_tokenメソッドが定義されていて、deviseを利用しているモデルのremember_tokenカラムの値がnilの場合は、remember_tokenの値を代入します。
現在時刻もremember_created_atに値が入っていなければ代入し、selfのattrのいずれかに変更があればvalidationをskipさせてレコードを保存していました。

呼び出し箇所はこちらです。

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      # Remembers the given resource by setting up a cookie
      def remember_me(resource)
        return if request.env["devise.skip_storage"]
        scope = Devise::Mapping.find_scope!(resource)
        resource.remember_me!
        cookies.signed[remember_key(resource, scope)] = remember_cookie_values(resource)
      end

forget_me!

devise/rememberable.rb at master · heartcombo/devise · GitHub

      # If the record is persisted, remove the remember token (but only if
      # it exists), and save the record without validations.
      def forget_me!
        return unless persisted?
        self.remember_token = nil if respond_to?(:remember_token)
        self.remember_created_at = nil if self.class.expire_all_remember_me_on_sign_out
        save(validate: false)
      end

レコードが保存されていなければ、remember_tokenとremember_created_atの値を条件を見つつnilにして、バリデーションをskipして保存しています。

こちらの呼び出し箇所はこちら。

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

rememberable_value

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      def rememberable_value
        if respond_to?(:remember_token)
          remember_token
        elsif respond_to?(:authenticatable_salt) && (salt = authenticatable_salt.presence)
          salt
        else
          raise "authenticatable_salt returned nil for the #{self.class.name} model. " \
            "In order to use rememberable, you must ensure a password is always set " \
            "or have a remember_token column in your model or implement your own " \
            "rememberable_value in the model with custom logic."
        end
      end

こちらは、Userモデルにインスタンスメソッドとして定義して、binding.pryでREPLで試してみます。

$ rails c

$ User.first.rememberable_value

# ここからREPL

$ respond_to?(:remember_token)
=> false # remember_tokenカラムがないためfalseが返る

$ respond_to?(:authenticatable_salt)
=> true

$ (salt = authenticatable_salt.presence)
=> "$2a$12$x6w/bZliXjk9NtMh23BGJ."

$ encrypted_password
=> "$2a$12$x6w/bZliXjk9NtMh23BGJ.GW64j1r8Mxg6clDGAcGIRxv6mc6J18q"

下記記事でも言及されていますが、saltはencrypted_passwordの前半部分が返り値になっています。

Devise3.2.2 のデフォルト設定では、Rememberable の remember_token のカラムがないのでソースを解読してみた | EasyRamble

つまり、rememberable_valueの返り値は、remember_tokenカラムがあればremember_tokenの値が、ない場合はencrypted_passwordの前半部分の値が返るメソッドでした。


remember_me?

devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      def remember_me?(token, generated_at)
        # TODO: Normalize the JSON type coercion along with the Timeoutable hook
        # in a single place https://github.com/heartcombo/devise/blob/ffe9d6d406e79108cf32a2c6a1d0b3828849c40b/lib/devise/hooks/timeoutable.rb#L14-L18
        if generated_at.is_a?(String)
          generated_at = time_from_json(generated_at)
        end

        # The token is only valid if:
        # 1. we have a date
        # 2. the current time does not pass the expiry period
        # 3. the record has a remember_created_at date
        # 4. the token date is bigger than the remember_created_at
        # 5. the token matches
        generated_at.is_a?(Time) &&
         (self.class.remember_for.ago < generated_at) &&
         (generated_at > (remember_created_at || Time.now).utc) &&
         Devise.secure_compare(rememberable_value, token)
      end

このメソッドの呼び出し箇所はこちら。
devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

      def remember_me_is_active?(resource)
        return false unless resource.respond_to?(:remember_me)
        scope = Devise::Mapping.find_scope!(resource)
        _, token, generated_at = cookies.signed[remember_key(resource, scope)]
        resource.remember_me?(token, generated_at)
      end

引数のgenerated_atはcookies.signedの返り値のようです。

The token is ...の部分を意訳します。

The token is only valid if:
1. we have a date
2. the current time does not pass the expiry period
3. the record has a remember_created_at date
4. the token date is bigger than the remember_created_at
5. the token matches

トークンは次の場合にのみ有効です。
1.generated_atがDateクラスのインスタンスであること
2.現在の時刻が有効期限を過ぎていないこと
3.レコードにremember_created_at日付があること
4.トークンの日付がremember_created_atよりも大きいこと
5.トークンが一致すること

出典: devise/rememberable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

上記の条件判定の結果を「認証状態を保持しているかどうか」という値としているメソッドでした。

おわりに

remember_token周りの実装の意図が最後までよくわかりませんでした、、、

が、どうやらOmniauthを使ってSNS認証でユーザー機能を実装しようとする際に、deviseのfriendly_tokenを使ってダミーのパスワードを作らないとRemember me機能が使えなくなる問題があるようで、

その対策のためにremember_tokenが存在するようです。

deviseのRemberable機能をデータベースにカラムを用意して使用する方法 - higan96技術メモ