devise-securityのsession_limitableモジュールのソースコードを追ってみた

こんにちは!kossyです!




さて、今回はdevise-securiyのsession_limitableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise-security 0.16.1



前提

devise-securityのsession_limitableモジュールをincludeしたUserモデルが定義されているものとします。

Devise::Models::SessionLimitable

まずはモジュールのコードをさらっと確認してみます。

# 省略

module Devise
  module Models
    # SessionLimited ensures, that there is only one session usable per account at once.
    # If someone logs in, and some other is logging in with the same credentials,
    # the session from the first one is invalidated and not usable anymore.
    # The first one is redirected to the sign page with a message, telling that
    # someone used his credentials to sign in.
    module SessionLimitable
      extend ActiveSupport::Concern
      include Devise::Models::Compatibility

      # Update the unique_session_id on the model.  This will be checked in
      # the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}
      # @param unique_session_id [String]
      # @return [void]
      # @raise [Devise::Models::Compatibility::NotPersistedError] if record is unsaved
      def update_unique_session_id!(unique_session_id)
        raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?

        update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
          Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
        end
      end

      # Should session_limitable be skipped for this instance?
      # @return [Boolean]
      # @return [false] by default. This can be overridden by application logic as necessary.
      def skip_session_limitable?
        false
      end
    end
  end
end

SessionLimited...から始まる部分をGoogle翻訳で訳してみます。

SessionLimited ensures, that there is only one session usable per account at once.
If someone logs in, and some other is logging in with the same credentials,
the session from the first one is invalidated and not usable anymore.
The first one is redirected to the sign page with a message, telling that someone used his credentials to sign in.

SessionLimitedは、アカウントごとに一度に使用できるセッションが1つだけであることを保証します。
誰かがログインし、他の誰かが同じ資格情報でログインしている場合、
最初のセッションからのセッションは無効になり、使用できなくなります。
最初のセッションは、誰かが自分の資格情報を使用してサインインしたことを通知するメッセージとともにサインページにリダイレクトされます。

平たく言ってしまうと、多重セッションを禁ずるモジュールですね。

上記の機能はどのようにして実現しているのでしょうか。


update_unique_session_id!

Devise::Models::SessionLimitableモジュールには2つのメソッドが定義されていて、そのうちの一つがupdate_unique_session_id!メソッドです。

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

Update the unique_session_id on the model. This will be checked in
the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}

モデル内のunique_session_idをupdateします。
これはWardenのafter_set_userフック内でチェックされます。(fileはdevise-security/hooks/session_limitable)

session_limitableを使うときに必要になる、unique_session_idをWardenのafter_set_userフック内でupdateするメソッドのようです。

  def update_unique_session_id!(unique_session_id)
    raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?

    update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
      Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
    end
  end

このメソッドをUserモデルに定義し、間にbinding.pryを挟んだ上で、ログインを試みます。

From: /app/app/models/supervisor.rb:94 Supervisor#update_unique_session_id!:

    90: def update_unique_session_id!(unique_session_id)
    91:   binding.pry
    92:   raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
    93:
 => 94:   update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
    95:     Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
    96:   end
    97: end

$ unique_session_id
=> nil

$ persisted?
=> true


update_attribute_without_validatons_or_callbacksメソッドを読んでみる必要がありそう。

update_attribute_without_validatons_or_callbacks

pry-byebugを導入していると使える、stepメソッドを使って update_attribute_without_validatons_or_callbacks の処理を見てみます。

From: /usr/local/bundle/gems/devise-security-0.16.0/lib/devise-security/models/compatibility/active_record_patch.rb:34 Devise::Models::Compatibility::ActiveRecordPatch#update_attribute_without_validatons_or_callbacks:

    33: def update_attribute_without_validatons_or_callbacks(name, value)
 => 34:   update_column(name, value)
    35: end

$ name
=> :unique_session_id

$ value
=> "buGqJFwGDV1bmXa39fgY"

ActiveRecordのupdate_columnメソッドを呼び出している処理でした。

update_columnメソッドでattributeの更新を行っているため、updated_at/updated_onの更新は行われません、、、

参考: 週刊Railsウォッチ(20181210)update_columnは要注意、DBカラムコメントは書こう、個人情報扱いの注意点、Capistranoはやっぱりいいほか|TechRacho by BPS株式会社


Warden::Manager.after_set_user

update_unique_session_id!メソッドの呼び出し元はWarden::Managerクラスのafter_set_userフックでした。

# After each sign in, update unique_session_id. This is only triggered when the
# user is explicitly set (with set_user) and on authentication. Retrieving the
# user from session (:fetch) does not trigger it.
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
  if record.devise_modules.include?(:session_limitable) &&
     warden.authenticated?(options[:scope]) &&
     !record.skip_session_limitable?

     if !options[:skip_session_limitable]
      unique_session_id = Devise.friendly_token
      warden.session(options[:scope])['unique_session_id'] = unique_session_id
      record.update_unique_session_id!(unique_session_id)
     else
      warden.session(options[:scope])['devise.skip_session_limitable'] = true
     end
  end
end

skip_session_limitableオプションがfalseでなければ、tokenを生成してwardenのsessionにunique_session_idを代入して、

.update_unique_session_id!メソッドを呼び出しています。

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

After each sign in, update unique_session_id.
This is only triggered when the user is explicitly set (with set_user) and on authentication.
Retrieving the user from session (:fetch) does not trigger it.

サインインするたびに、unique_session_idを更新します。
これは、ユーザーが(set_userを使用して)明示的に設定され、認証された場合にのみトリガーされます。
セッション(:fetch)からユーザーを取得しても、トリガーされません。

呼び出されるタイミングが記載されていました。

skip_session_limitable?

      # Should session_limitable be skipped for this instance?
      # @return [Boolean]
      # @return [false] by default. This can be overridden by application logic as necessary.
      def skip_session_limitable?
        false
      end

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

Should session_limitable be skipped for this instance?
@return [Boolean]
@return [false] by default. This can be overridden by application logic as necessary.

session_limitableをスキップする必要があるかどうかを制御できます。
もしスキップしたい場合は必要に応じてアプリケーションロジックによって上書きできます。

trueを返すようにすれば、unique_session_idカラムの値を更新しなくなるようでした。

まとめ

update_columnを使っているため、timestamp機能が有効でもupdated_at/updated_on属性が更新されない問題があるみたいです、、、

自力で直せるか検討してみたんですが、Railsのtimestamp機能のprivateメソッドを直に呼び出すような案しか思いつきませんでした。

OSSコントリビュートのチャンスだと思うんですが、思ったよりも考慮事項が多そうに思いました。どこかで直したいけど。