deviseのdestroyアクションを実行すると何が起こるか調べてみた

こんにちは!kossyです!




今回は、deviseのdestroyアクションを実行すると何が起こるか調べてみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 3.0.3
Rails 6.0.4
devise 4.8.1



前準備

pry-railsとpry-byebugをGemfileに記載してbundleした後、

sessions_controller.rbのdestroyアクションにbinding.pryを仕込みます。

    25: def destroy
    26:   binding.pry
    27:   signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    28:   set_flash_message! :notice, :signed_out if signed_out
    29:   yield if block_given?
    30:   respond_to_on_destroy
    31: end

その後、適当なアカウントでログインして、ログアウトボタンを押してREPLを起動できたらOKです。

Devise.sign_out_all_scopes

From: /app/app/controllers/devise/sessions_controller.rb:27 Devise::SessionsController#destroy:

    25: def destroy
    26:   binding.pry
 => 27:   signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    28:   set_flash_message! :notice, :signed_out if signed_out
    29:   yield if block_given?
    30:   respond_to_on_destroy
    31: end

> Devise.sign_out_all_scopes
=> true

Devise.sign_out_all_scopesはconfigファイルで設定できる値かと思われるので、見に行ってみます。

# Set this configuration to false if you want /users/sign_out to sign out
# only the current scope. By default, Devise signs out all scopes.
# config.sign_out_all_scopes = true

もし users/sign_out でサインアウトさせたい場合は false にする必要があるようです。

デフォルト値は true のようです。

「Deviseはすべてのスコープをサインアウトします。」と記載があるため、

例えば管理者権限と一般ユーザー権限の2つのモデルを作成し、両方の権限でログインしてからログアウトすると、全ての権限でログアウトになると思われます。


sign_out

github.com

def sign_out(resource_or_scope = nil)
  return sign_out_all_scopes unless resource_or_scope
  scope = Devise::Mapping.find_scope!(resource_or_scope)
  user = warden.user(scope: scope, run_callbacks: false) # If there is no user

  warden.logout(scope)
  warden.clear_strategies_cache!(scope: scope)
  instance_variable_set(:"@current_#{scope}", nil)

  !!user
end

こちらはREPLで試しながら動作を確認してみます。

$ resource_or_scope
=> nil

このまま1行ずつ実行すると sign_out_all_scopes が実行されてしまうので、resource_or_scopeに値を入れた上で試してみます。

$ resource_or_scope = User.first

$ scope = Devise::Mapping.find_scope!(resource_or_scope)
=> :user

$ user = warden.user(scope: scope, run_callbacks: false)
=> #<User id: 1, ...>

$ warden.logout(scope)
=> nil

$ warden.clear_strategies_cache!(scope: scope)
=> {}

$  instance_variable_set(:"@current_#{scope}", nil)
=> nil

$ !!user
=> true

実際にsessionを削除するような処理はwardenの中で行っているようなので、wardenの処理にstepメソッドで入ってみたいと思います。

From: /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:267 Warden::Proxy#logout:

    266: def logout(*scopes)
 => 267:   if scopes.empty?
    268:     scopes = @users.keys
    269:     reset_session = true
    270:   end
    271:
    272:   scopes.each do |scope|
    273:     user = @users.delete(scope)
    274:     manager._run_callbacks(:before_logout, user, self, :scope => scope)
    275:
    276:     raw_session.delete("warden.user.#{scope}.session") unless raw_session.nil?
    277:     session_serializer.delete(scope, user)
    278:   end
    279:
    280:   reset_session! if reset_session
    281: end

> scopes.empty?
=> false

> user = @users.delete(scope)
=> #<User id: 1, ...>

> raw_session
=> #<ActionDispatch::Request::Session:0x00007fb208019c00 ...>

raw_sessionおよびsession_serializerからsessionをdeleteして、

Railsのreset_session!メソッドを呼び出しているようです。

railsdoc.com

と思いきや、warden_compat.rbのメソッドらしいです。

> method(:reset_session!).source_location
=> ["/usr/local/bundle/gems/devise-4.8.0/lib/devise/rails/warden_compat.rb", 8]
module Warden::Mixins::Common
  def request
    @request ||= ActionDispatch::Request.new(env)
  end

  def reset_session!
    request.reset_session
  end

  def cookies
    request.cookie_jar
  end
end

実際には ActionDispatch::Request クラスのreset_sessionメソッドが呼ばれているようです。

reset_session (ActionController::Base) - APIdock

raw_sessionについての知見が皆無なので調べてみます。

raw_session

先ほどstepで入った処理の中で色々実行してみました。

> raw_session.class
=> ActionDispatch::Request::Session

> raw_session.methods
=> [:loaded?, :to_hash, :delete, :clear, :exists?, :to_h, ...]

> raw_session.to_h
=>  {"session_id"=>"b71549ad67aa9d67e27aa04fea2c0b37",
 "warden.user.user.key"=>[[1], "$2a$12$oLbqXNXAJ3P17TM.7bfaiu"],
 "warden.user.user.session"=>{"unique_session_id"=>"jj2nrehY8TNM-f_nrryY"},
 "_csrf_token"=>"KbKDaYOjWm7jMLPfqctmdhG3ImpMGoQ4y9JX89plxLc="}

> raw_session.method(:to_h).source_location
=> ["/usr/local/bundle/gems/actionpack-6.0.4.1/lib/action_dispatch/request/session.rb", 143]

action_dispatch/request/session.rb を見に行けばよさそう。

github.com

読んでもよくわからなかったので公式ドキュメントを見てみます、、、

Class: ActionDispatch::Request::Session — Documentation for rails (6.0.2.1)

Session is responsible for lazily loading the session from store.

セッションは、ストアからセッションを遅延ロードする責任があります。

出典: https://www.rubydoc.info/docs/rails/ActionDispatch/Request/Session

raw_sessionの役割についてはわかりましたが、定義元がよくわからないので調べてみます。

> method(:raw_session).source_location
=> ["/usr/local/bundle/gems/warden-1.2.9/lib/warden/mixins/common.rb", 9]

github.com

# Convenience method to access the session
# :api: public
def session
  env['rack.session']
end # session

# Alias :session to :raw_session since the former will be user API for storing scoped data.
alias :raw_session :session

env['rack.session']の返り値だったみたいです。

rack.sessionに値が書き込まれるタイミングはいつなのか?が気になりますが、返り値も定義元も役割もわかったので一旦ここまでにします。

session_serializerもいまいちわからんので調べます。

session_serializer

> session_serializer
=> #<Warden::SessionSerializer:0x00007f99501d5790 ...

> method(:session_serializer).source_location
=> ["/usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb", 50]

github.com

def session_serializer
  @session_serializer ||= Warden::SessionSerializer.new(@env)
end

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

Points to a SessionSerializer instance responsible for handling everything related with storing, fetching and removing the user session.

ユーザーセッションの保存、フェッチ、および削除に関連するすべての処理を担当するSessionSerializerインスタンスを指します。

出典: https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L46

CRUD全てを担当するクラスっぽいですね。定義元を見に行ってみます。

github.com

deleteメソッドの中身を見てみます。

def delete(scope, user=nil)
  session.delete(key_for(scope))
end

# We can't cache this result because the session can be lazy loaded(セッションを遅延ロードするためにキャッシュを行いません)
def session
  env["rack.session"] || {}
end

def key_for(scope)
  "warden.user.#{scope}.key"
end

deleteメソッドはsessionから warden.user.user.key のようなkeyをdeleteするメソッドでした。

先ほど、「rack.sessionに値が書き込まれるタイミングはいつなのか?」と疑問を書いていましたが、storeメソッドが書き込んでいそうなので処理を追ってみます。

def store(user, scope)
  return unless user
  method_name = "#{scope}_serialize"
  specialized = respond_to?(method_name)
  session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user)
end

storeメソッドの呼び出し元がこちらでした。

github.com

def set_user(user, opts = {})
  scope = (opts[:scope] ||= @config.default_scope)

  # Get the default options from the master configuration for the given scope
  opts = (@config[:scope_defaults][scope] || {}).merge(opts)
  opts[:event] ||= :set_user
  @users[scope] = user

  if opts[:store] != false && opts[:event] != :fetch
    options = env[ENV_SESSION_OPTIONS]
    if options
      if options.frozen?
        env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
      else
        options[:renew] = true
      end
    end
    session_serializer.store(user, scope)
  end

  run_callbacks = opts.fetch(:run_callbacks, true)
  manager._run_callbacks(:after_set_user, user, self, opts) if run_callbacks

  @users[scope]
end

set_userは_perform_authenticationというprivateメソッドで呼ばれています。

github.com

なので、「rack.sessionに値が書き込まれるタイミングはいつなのか?」という問いは、「_perform_authenticationメソッドでset_userが呼ばれた時」が一つの解かと思います。

ログアウトのことを調査していたのにログイン時の挙動の調査をしてしまいました、、、寄り道はここまでにします。

まとめ

destroyアクションが呼ばれると、

「Devise.sign_out_all_scopesが true なら、

全てのdeviseを使っている認証中のモデルのwardernのsessionを削除し、

falseなら引数に与えられたresourceのモデルのwardenのsessionを削除」

していました。

「wardenのsessionを削除」は、Warden::Proxy#logout メソッドが呼ばれていて、

内部では request.env['rack.session'].clear を呼んでsessionの中身をnilにすることでログアウトを実現してました。

今までログアウトについて、cookie初期化してsession削除してるんだろうな、程度の曖昧な理解に留まっていましたが、実際に処理を追ってみることで、

ログアウトの詳細な動作とwardenの仕組みの一端を理解することができました。

認証周りのコードを読めば読むほど、自分で認証周りを自作してはいけない気持ちになりますね。