1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails + Devise 環境におけるログイン処理をきちんと整理する

Last updated at Posted at 2025-05-26

まえがき

 こんにちは。普段はRailsでアプリケーションを作っている者です。
 先日、関わっているRailsアプリケーションのログイン回りでCSRFトークン認証エラー(ActionController::InvalidAuthenticityToken: Can't verify CSRF token authenticity.)が出ていて、その原因究明を行いました。
 その際にDevisesessionの動作を深掘りしたのですが、意外とこの辺を整理して記載している記事が見当たらなかったので、せっかくならと思い調査の内容を記事にしました。どなたかなの参考になれば幸いです。

対象読者

普段Deviseやsessionをなんとなくで使っている方

得られる知識

  • Railsのsessionハンドリングの仕組み
  • Deviseによるログイン時の動作
  • Deviseとサーバーセッション(RedisStoreやActiveRecordStore)を使っている状況下における、CSRFトークン検証エラー発生の条件と原因

本文

sessionはいつどこでロードされ、いつどこで書き換えられるのか?

 まずはsessionから見ていきます。なぜかというと、CSRFトークンやDeviseがハンドリングする(正確には、DeviseがWardenを使ってハンドリングする)認証情報はsessionに紐づいて管理されるからです。

 Railsにおけるセッション管理は、主にRack Middleware層で行われています。具体的には、use ActionDispatch::Session::(任意のもの)Storeという形で設定されるミドルウェアが担当します。

※Rackミドルウェアを知らない方は、以下の記事がおすすめです。
https://zenn.dev/igaiga/books/rails-practice-note/viewer/rack_middleware_and_rack

 ミドルウェアの選択肢としてはActiveRecordStoreRedisStoreCookieStoreなどがありますが、これらは全てRack::Session::Abstract::Persistedインターフェースを実装しています。

 SessionミドルウェアのエントリーポイントであるRack::Session::Abstract::Persistedのcallメソッドを見てみましょう。

# rack/session/abstract/id.rb
module Rack
  module Session
    module Abstract
      class Persisted
        # ...
        def call(env)
          context(env)
        end

        def context(env, app = @app)
          req = make_request env
          # リクエストからセッションを準備。session_idがあればロードし、
          # なければ発行してenv["rack.session"]に格納
          prepare_session(req)
          # 下のミドルウェア層を呼び出し。
          # env["rack.session"]はDeviseやWardenによって操作される
          status, headers, body = app.call(req.env)

          # アプリケーション側の処理が終わり、スタックを登ってきた後、
          # セッションをコミット(ここで初めてStoreへの書き換えが起きる)
          res = Rack::Response::Raw.new status, headers
          commit_session(req, res)
          [status, headers, body]
        end
        # ...
      end
    end
  end
end

 このコードからわかるのは、sessionがリクエスト処理の開始時(prepare_session)にロード、または新規生成され、レスポンスがミドルウェアスタックを遡る際(commit_session)に初めてストレージにコミット(書き換え)されるということです。

 続いてcommit_sessionメソッドを見ていきましょう。commit_sessionメソッドもAbstract::Persistedに実装されています。ここでは、sessionの内容をStoreに保存し、変更内容をレスポンスのSet-Cookieヘッダーに含めます。

# rack/session/abstract/id.rb
def commit_session(req, res)
  session = req.get_header RACK_SESSION
  options = session.options

  if options[:drop] || options[:renew] # <= ここに注目!
    session_id = delete_session(req, session.id || generate_sid, options)
    return unless session_id
  end

  # ... (セッションデータの永続化処理) ...

  # Set-Cookieヘッダーの設定
  set_cookie(req, res, cookie.merge!(options))
end

 ここで覚えておいて欲しいのは、commit_sessionはoption(env['rack.session'])をとっており、drop、renewがついているときはdelete_sessionメソッドを呼び出すということです。

 では続いて、delete_sessionメソッドを見ていきます。
 delete_sessionAbstract::Persistedでは抽象メソッドとして定義されており、具体的な実装はActiveRecordStoreなどの各Storeクラスにあります。

 ActiveRecordStoreの実装を見てみましょう。

# action_dispatch/session/active_record_store.rb
def delete_session(request, session_id, options)
  logger.silence do
    if sid = current_session_id(request)
      if model = get_session_with_fallback(sid)
        data = model.data # dataを変数に一時保存
        model.destroy     # レコードを削除
      end
    end

    request.env[SESSION_RECORD_KEY] = nil

    unless options[:drop]
      new_sid = generate_sid

      if options[:renew] # 新しいsession_idを持ったインスタンスを作成し、データを移行して保存
        new_model = session_class.new(:session_id => new_sid.private_id, :data => data)
        new_model.save
        request.env[SESSION_RECORD_KEY] = new_model
      end
      new_sid
    end
  end
end

 ここで見て欲しいのは、delete_sessionが呼ばれた場合、dropだと完全に削除、renewの場合はデータだけ新しいsessionレコードに移行して保存しているということです。

 どちらのオプションであっても既存のsessionレコードは完全に削除されます。つまり、ここで消された後に同じsession_idを持つリクエストが来たとしても、ActiveRecordSessionは対象のsessionを見つけられないということです。

Deviseはログイン処理時になにをしているのか

 sessionの生成、保存、更新処理の流れがわかったところで、いよいよDeviseのログイン処理について見ていきましょう。

 Deviseを使ってログイン用のControllerを作成するとき、次のようにDevise::SessionsControllerを継承したコントローラーを作成します。メインの処理を追うにはDevise::SessionsControllerを見てみれば良さそうですね

class YourSessionsController < Devise::SessionsController
  # ...
  # [POST] /users/sign_in(.:format)
  def create
    super do |user|
      # some_custom_operation
    end
  end
  # ...
end

 Devise::SessionsController#createを見てみると、sing_inというメソッドがコアの処理のようです。

# controllers/devise/sessions_controller.rb
class Devise::SessionsController < DeviseController
  # ...
  def create
    self.resource = warden.authenticate!(auth_options)
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource) # <= ここがコアの処理
    yield resource if block_given?
    respond_with resource, location: after_sign_in_path_for(resource)
  end
  # ...
end

 sign_inメソッドは以下のようにDevise::Controllers::SignInOutに定義されており、主にWardenのハンドリングを行っています。
(※WardenとはRackアプリケーションにユーザー認証の仕組みを提供するgemです。Deviseの認証基盤自体はWardenに依存しており、DeviseはWardenをRailsアプリで便利に使えるようにラップしているgem、と捉えることができます)

# devise/controllers/sign_in_out.rb
module Devise
  module Controllers
    module SignInOut
      # ...
      def sign_in(resource_or_scope, *args)
        # ...
        # Warden::Proxy#set_userを呼び出している
        warden.set_user(resource, options.merge!(scope: scope))
      end
      # ...
    end
  end
end

 どうやらWarden::Proxy#set_userがメインの処理ということがわかりましたね。それでは対象コードを見にいきましょう。ここからはDeviseではなく、Wardenの世界に入ることになります。

# warden/proxy.rb
module Warden
  class Proxy
    # ...
    def set_user(user, opts = {})
      scope = (opts[:scope] ||= @config.default_scope)

      # ...

      if opts[:store] != false && opts[:event] != :fetch
        options = env[ENV_SESSION_OPTIONS]
        if options # <= このif節でrenewオプションを渡している
          if options.frozen?
            env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
          else
            options[:renew] = true
          end
        end
        session_serializer.store(user, scope) # <= sessionに認証情報を書き込み
      end
      # ...
    end
    # ...
  end
end

 上のコードから、Wardenはset_userするときに、デフォルトではenv['rack.session.options']にrenewオプションを渡すことがわかります。つまり、特に設定をしない場合、Ddeviseのログイン処理はログイン前のsessionを(commit_sessionで)削除する、ということです。

 Rails + Devise 環境におけるログイン処理の整理は以上です。お疲れ様でした。

(おまけ)どんなときにCSRFトークンエラーが発生するのか

 ここまで、CSRFトークンエラーの原因を突き止めるために調査を行ってきました。今までの知識を統合すれば、ログイン処理でなぜCSRFトークン検証エラーが発生していたかがわかります。
 それは、先行するログインが成功してcommit_sessionでsessionが削除されてしまったとき。つまり、同じsession_idとそれに紐づくCSRF認証トークンでログインリクエストを連続送信してしまった時です。

具体的なケース

 ログインボタンの二重クリックが制限されていなかったり、何かしらのネットワーク的な不具合でユーザーが二重にログインリクエストを送ったケースを考えてみましょう。

  1. 1回目のリクエスト
    1. ログイン前のsession(id = 1 とする)とsession_1[:_csrf_token]を使ってCSRF検証が成功する
    2. ログイン認証も完了して、Warden::Proxy#set_userによってrack.session.options:renewオプションが付けられる
    3. ミドルウェアスタックに戻った時commit_sessionが実行されて、session_1が削除され、新しいsession(id = 2 とする)が作成・保存される
    4. この時点で、このレスポンスはまだユーザーには届いていない
  2. 2回目のリクエスト
    1. 1回目のレスポンスが届くより先に、2回目のリクエストを送信する(このとき、cookieから送られるsession_idは1のまま)
    2. Sessionミドルウェアはsession_1を探すが、見つからないので新しいsessionを発行する
    3. CSRFを検証するが、新しいsessionには[:_csrf_token]がないので認証に失敗する

 以上のようにレースコンディションが発生して認証が失敗するわけです。
 ちなみに、上記の不具合が起きるのはサーバー側でデータを保存するサーバーセッションのみで、ユーザー側にセッションデータを保存するCookieStoreの場合は失敗しないはずです。

 まあそもそも、リクエストを二重で送信できないようにしっかり実装しておけば、発生しない事象ではありますね。

まとめと感想

  • session は Rackミドルウェア層(ActionDispatch::Session::XXXStore)で読み込み => 書き込みされる
  • :drop:renewオプションによって、session は破棄・再生成される
  • DeviseWarden)はデフォルトでログイン時に:renewオプションを発行している
  • ログイン後はsessionが消えてしまうので、古いsession_idを使ったリクエストを送るとCSRFトークン認証に失敗するようになる

 DeviseやWardenはとても便利に使えて、きちんと理解してい人でもある程度扱えてしまいます。とても良いことですが、その便利さに甘えていたままだと思いもしない不具合や、よろしくない実装を招く可能性が高まります。
 そのため、たまにはこうやってソースコードをトレースして、ライブラリが上手に隠してくれている実装を紐解くことも必要だと感じました。

 別件ですが、ActionDispatch::Session::XXXStore系の実装は感動を覚えるほど綺麗な実装でした。美しい……。ぜひ見てみてください。
 それでは。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?