まえがき
こんにちは。普段はRailsでアプリケーションを作っている者です。
先日、関わっているRailsアプリケーションのログイン回りでCSRFトークン認証エラー(ActionController::InvalidAuthenticityToken: Can't verify CSRF token authenticity.)が出ていて、その原因究明を行いました。
その際にDeviseとsessionの動作を深掘りしたのですが、意外とこの辺を整理して記載している記事が見当たらなかったので、せっかくならと思い調査の内容を記事にしました。どなたかなの参考になれば幸いです。
対象読者
普段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
ミドルウェアの選択肢としてはActiveRecordStore、RedisStore、CookieStoreなどがありますが、これらは全て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_sessionはAbstract::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回目のリクエスト:
- ログイン前のsession(id = 1 とする)とsession_1[:_csrf_token]を使ってCSRF検証が成功する
- ログイン認証も完了して、
Warden::Proxy#set_userによってrack.session.optionsに:renewオプションが付けられる - ミドルウェアスタックに戻った時
commit_sessionが実行されて、session_1が削除され、新しいsession(id = 2 とする)が作成・保存される - この時点で、このレスポンスはまだユーザーには届いていない
-
2回目のリクエスト:
- 1回目のレスポンスが届くより先に、2回目のリクエストを送信する(このとき、cookieから送られるsession_idは1のまま)
- Sessionミドルウェアはsession_1を探すが、見つからないので新しいsessionを発行する
- CSRFを検証するが、新しいsessionには[:_csrf_token]がないので認証に失敗する
以上のようにレースコンディションが発生して認証が失敗するわけです。
ちなみに、上記の不具合が起きるのはサーバー側でデータを保存するサーバーセッションのみで、ユーザー側にセッションデータを保存するCookieStoreの場合は失敗しないはずです。
まあそもそも、リクエストを二重で送信できないようにしっかり実装しておけば、発生しない事象ではありますね。
まとめと感想
- session は Rackミドルウェア層(
ActionDispatch::Session::XXXStore)で読み込み => 書き込みされる -
:dropや:renewオプションによって、session は破棄・再生成される -
Devise(Warden)はデフォルトでログイン時に:renewオプションを発行している - ログイン後はsessionが消えてしまうので、古いsession_idを使ったリクエストを送るとCSRFトークン認証に失敗するようになる
DeviseやWardenはとても便利に使えて、きちんと理解してい人でもある程度扱えてしまいます。とても良いことですが、その便利さに甘えていたままだと思いもしない不具合や、よろしくない実装を招く可能性が高まります。
そのため、たまにはこうやってソースコードをトレースして、ライブラリが上手に隠してくれている実装を紐解くことも必要だと感じました。
別件ですが、ActionDispatch::Session::XXXStore系の実装は感動を覚えるほど綺麗な実装でした。美しい……。ぜひ見てみてください。
それでは。