Laravel の Session 周りの仕組みを理解して、多重送信対策する
はじめに
株式会社じげんの大西です。
パートナーソリューション Div. という部署で、エンジニアとしてフロントエンドからバックエンド、インフラの開発・運用保守をしております。
今回は、Laravel の Session 周りのコードを読んできます。
Laravel で用意されている CSRF 対策で用いるトークンを利用して多重送信対策を行おうと思うのですが、多重送信されてしまうケースがあり、挙動がわからなかったので、実際にフレームワークのソースコードを読んでみることにしました。
Laravel の Session 周りの挙動が気になる方の参考記事として、また、コードリーディングをやってみたい方の第一歩としての一助となれば幸いです。
環境
Laravel 6.20
PHP 7.4
多重送信対策の方法について
CSRF 対策で用いるトークンを利用して多重送信対策する
CSRF 対策で用いる、自動的に生成された一時的なランダムトークンは、POSTリクエストを一意に識別するために使用するために、以下の2つの場所に保存されます
- セッションとして保持
- HTML <input type=”hidden” value=”token-xxxxx”> の input タグの value として設置
サーバーが POST リクエストを受信すると、フォーム送信データのトークンとセッションにあるトークンがチェックされ、正規のリクエストかどうかを判断するというものです。
この機能を使って、どのように多重送信対策をするかというと、下記の一行の処理を追加して対策します。リクエストの処理が完了した後にサーバー側のトークンを作り直しています。
$request->session()->regenerateToken();
サーバーに保存されているトークンを再生成することによって、2回目以降のリクエストは送信されたトークンチェックが不一致となり、正規のリクエストと見做さないような処理をします。
// Controller
public function store(Request $request) {
// 二重送信を防ぐため token を再生成しておきます。
$request->session()->regenerateToken();
Sample::create([
'sample_text' => $request->sample_text,
]);
return redirect()->to('/');
}
しかし、速めの連打を行うと、重複してデータが送信されてしまっているケースがあったため、Laravel フレームワークのソースコードを読んで仕組みを理解することにしました。
Session 周りのコードを読んで原因を探す
動作の挙動と、CSRF トークンが書きかわっていないことから、多重送信の原因の予想しておきます。2 回目以降のクリックのリクエストで、トークンが書きかわらず、トークンの判定が機能しなかった結果多重送信されてしまうと考えられるので、トークンが付与されるタイミングから判定される間の仕組みのコードを読んでいくことにしました。
composer を使っているので、vendor ディレクトリの中のソースコードに、PsySH (PHP の REPLライブラリ) でデバッグしながら読んできました。
コード解説
セッションの取得と開始
まずはどこでトークンが設置、使用されているかを確認するため、セッション周りの下記のコードを読んでいきます。
Illuminate\Session\Middleware
StartSession.php
public function handle($request, Closure $next)
{
if (! $this->sessionConfigured()) {
return $next($request);
}
$request->setLaravelSession(
$session = $this->startSession($request) ...(1)
);
$this->collectGarbage($session);
// 次のミドルウェアにHTTPリクエストが渡されている
$response = $next($request); ...(2)
$this->storeCurrentUrl($request, $session);
$this->addCookieToResponse($response, $session);
$this->saveSession($request); ...(3)
return $response;
}
(1) まずは、startSession() の一連の処理の流れを確認します。
セッションの取得
namespace Illuminate\Session\Middleware
// L78 あたり
protected function startSession(Request $request)
{
return tap($this->getSession($request), function ($session) use ($request) {
$session->setRequestOnHandler($request); ...(1-1)
$session->start(); ...(1-2)
});
}
// L85 あたり
public function getSession(Request $request) ...(1-1)
{
return tap($this->manager->driver(), function ($session) use ($request) {
$session->setId($request->cookies->get($session->getName()));
});
}
こちらの startSession() でセッションが開始されます。
(1-1) getSession()
でリクエストからセッションが保持している値が読みこまれています。
セッションをのインスタンスを取得と、config/session.php にて設定しているクッキー名を取得して( $session->getName()
) その値を セッション ID としてセットします。
(1-2) セッションを取得できたので、$session->start();
でセッションを開始します。/Illuminate/Session/Store の start() が呼ばれます。呼ばれた先が下記です。
セッション開始
public function start()
{
$this->loadSession(); ...(1-2-1)
if (! $this->has('_token')) {
$this->regenerateToken(); ...(1-2-2)
}
return $this->started = true;
}
protected function loadSession() ...(1-2-1)
{
$this->attributes = array_merge($this->attributes, $this->readFromHandler());
}
(1-2-1) loadSession()
では セッション情報が書かれたファイルを取り出し、その値を $this->attributes
の配列へ格納しています。
(1-2-2) _token (CSRF トークン)を持っているかの確認、なければ生成しトークンが保持されます。
ここで、CSRF トークンの存在確認、生成をしており、トークンを扱っていることが確認できました。多重送信の原因の予想としては、トークンが書きかわっていないことを挙げていたので、もう少し読み進めてみます。
// 次のミドルウェアにHTTPリクエストが渡されている
$response = $next($request); ...(2)
...
$this->saveSession($request); ...(3)
return $response;
protected function saveSession($request)
{
$this->manager->driver()->save();
}
(2) ここの先で HTTP リクエストの処理をおこなっているようで、Middleware の VerifyCsrfToken にてセッショントークンとリクエストトークンが等しいかどうかの検証も行われます。
Illuminate\Http\Response オブジェクトが返ってきます。
(3) レスポンスを返す前に、セッションのデータが Store に保存されます。
ここで regenerateToken() で再生成したトークンも Store へ書き込まれることになります。
ここまでで、リクエストからセッションの処理後、レスポンスを返すまで一通り確認できたので、実際に送信ボタンを連打し、ログ出力しながら多重送信の挙動を見ていきます。
多重クリックした時の挙動の確認
POST するデータを保存する時とセッションをストアする時の前でログに出力させてみます。(もっと良い方法があるかも)
// Controller
public function store(Request $request) {
// 二重送信を防ぐため token を再生成しておきます。
$request->session()->regenerateToken();
logger('create data'); // ログ出力
Sample::create([
'sample_text' => $request->sample_text,
]);
return redirect()->to('/');
}
// Illuminate\Session\Middleware
// StartSession.php
// 次のミドルウェアにHTTPリクエストが渡されている
$response = $next($request); ...(2)
...
logger('----- session store -----'); // ログ出力
$this->saveSession($request); ...(3)
return $response;
ログ出力結果とデータの状態は以下のようになりました。
▼ ログ出力結果
[2022-11-22 03:40:38] local.DEBUG: create data // ここでデータ保存している
[2022-11-22 03:40:38] local.DEBUG: create data // ここでデータ保存している
[2022-11-22 03:40:38] local.DEBUG: ----- session store -----
[2022-11-22 03:40:38] local.DEBUG: ----- session store -----
[2022-11-22 03:40:38] local.ERROR: 419:TokenMismatchException
[2022-11-22 03:40:38] local.DEBUG: ----- session store -----
[2022-11-22 03:40:38] local.ERROR: 419:TokenMismatchException
[2022-11-22 03:40:38] local.DEBUG: ----- session store -----
▼ DB に保存されたデータ
セッションがストアされる前に、create data が2回走っていることがわかります。DB からも重複してデータが保存されていることが確認できました。
連打して多重リクエストした時、初回リクエストのセッションのストアへの書き込みが終わっていない状態で、次のリクエストが始まっていました。そのため、次のリクエストにおいてもストアへの書き込み前のトークンが使用され、一致している判定となり、正規リクエストとして重複でデータが保存されてしまっていたようでした。
まとめ
初回リクエストのセッションのストアへの書き込みが終わっていない状態で、次のリクエストが始まってしまうことによって、Controller 側でトークンの再生成 $request->session()->regenerateToken();
を行ったとしても、Middleware でのトークンチェックにて、初回リクエスト時と同じセッションデータのトークンが呼び出され、多重送信できてしまっていました。
対応策
対応策としては、バックエンドだけでなく、フロントエンド側でボタンの制御を行うことでの多重クリック防止の対応になるかと思います。
今回コードリーディングする中で、CSRFトークンの利用と二重送信防止は目的も実現方法も全く異なるものでは?とも感じましたので、コントローラの前でトークンを再生成するなど、他の方法を模索していきたいなと思いました。
また、どんな時に多重クリックしてしまうのかといったところも考えて対策するもいいのかもしれないです。(例えば、処理が重くて、ユーザーが送信できていないと思い、何度もクリックしてしまうケースなど)
コードリーディングやってみて
以前に CSRF 対策の処理部分を読んだことがあったので、抵抗はなかったのですが、原因を推測して、ある程度目星をつけて検証しながら読み進めるとより理解しやすく、読んでいて楽しかったです。Session 周りは読みきれない部分もあり読み飛ばした箇所もあったので、引き続き読んでいこうと思います。
最初は難しく感じますが、読むことに慣れる感覚もありました。知らなかった関数や、コードの書き方も参考にできるので、今後も気になる部分は積極的に読んでいきたいと思います。
また、間違いなどがありましたら指摘していただけるとありがたいです。
参考
framework/StartSession.php at 6.x · laravel/framework · GitHub