初めに
Laravelを使用している方でLaravelの扱い方に慣れていないころ、419エラー
に出会った方は割といるのではないかと思います。
今回はその419エラー
を生み出しているLaravelのCSRF対策の仕組みついて実際のコードから見ていきます!
環境
ツール | バージョン |
---|---|
PHP | 7.4.8 |
Laravel | 8.14.0 |
CSRF対策とは?
CSRF(クロスサイト・リクエスト・フォージェリ)とは、ざっくり言うと罠サイト等から他の人のブラウザのCookieに書かれているセッションIDを取得し、そのIDを使用するなどの方法で特定のWebアプリケーションへアクセスすることで、元のセッションのデータの持ち主がリクエストをしたように偽装することです。詳しくはIPAの記事を見てみて下さい。
CSRF対策の一例
体型的に学ぶ安全なWebアプリケーションの作り方 第二版 4.5節 「重要な処理」の際に混入する脆弱性
より
入力画面
<?php
session_start();
if (empty($_SESSION['token'])) {
$token = bin2hex(openssl_random_pseudo_bytes(24));
$_SESSION['token'] = $token;
} else {
$token = $_SESSION['token'];
}
?>
<form action="45-003a.php" method="POST">
新パスワード<input name="pwd" type="password"><br>
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_COMPAT, 'UTF-8');?>">
<input type="submit" value="パスワード変更">
</form>
結果を映す画面
<?php
session_start();
$token = filter_input(INPUT_POST, 'token');
if (empty($_SESSION['token']) || $token !== $SESSION['token']) {
die('正規の画面からご利用ください。'); //エラーメッセージ
}
//成功時の動作
?>
このプログラムが行っていることはこの3つです。
- フォーム入力画面を作成時にPHPの暗号を生成する関数(openssl_random_pseudo_bytes)を実行し、その結果をセッションへセットする。
- セッションへセットしたデータはhtmlの
<form></form>
内にtoken
という名前でセットし、他の入力データと共にHTTPのPOSTリクエスト実行から結果を表示するプログラムへ送る。 - 入力画面から送られてきたデータのうち
token
の名前に入っているデータを取り出し、それと入力画面作成時にセッションへセットしたデータを比べ、同じであれば成功時の処理を、違えばそこで処理を終了する。
図で表すとこのようになります。
次にLaravel内でのCSRF対策の処理を見ていきます。
LaraveのCSRF対策のコードを見てみる
コードを見る前に
LaravelのCSRF対策のコードは多数のクラスのメソッドによって成り立っているため、この章以降のコードを追うのみではCSRF対策の構造が分かりにくく、この記事自体を見る気が萎える方も出てしまう方も出てしまうかもしれません。なので、Laravelのコードを見る前にLaravelのデフォルトのCSRF対策のプログラムでやっていることと、そのプログラムの進行の図をここでお見せします。もし、この章以降のコードを追う中でCSRF対策の構造の理解がしにくいな、と感じた場合はここを見返してみて下さい。
LaravelのCSRF対策でやっていることは、暗号作成時にその暗号の値を/storage/framework/sessions/暗号の値
へファイルとして保存し、その暗号の照合時に、リクエスト内で@csrf
などでhtmlの<form></form>
内に_token
という名前セットされたその暗号の値と作成時に作られたファイルに記された暗号の値を比べる、ということです。つまり、LaravelのデフォルトのCSRF対策では$_SESSION
は用いていないということです。
図で表すとこんな感じです。
前振りはここまでにして、さっそくLaravelの実際のコードからCSRF対策の仕組みについて見ていきます。
セッションをチェックしているコードを見てみる
今回はセッションをチェックしているコードのうち、デフォルトでセットされているVerifyCsrfToken
に焦点を当てました。
VerifyCsrfToken
名前空間 Illuminate\Foundation\Http\Middleware
public function handle($request, Closure $next)
{
if (
$this->isReading($request) || // .....(1)
$this->runningUnitTests() || // .....(2)
$this->inExceptArray($request) || // .....(3)
$this->tokensMatch($request) // .....(4)
) {
return tap($next($request), function ($response) use ($request) {
if ($this->shouldAddXsrfTokenCookie()) {
$this->addCookieToResponse($request, $response);
}
}); // .....(5)
}
throw new TokenMismatchException('CSRF token mismatch.');
}
セッションのチェックを実行するhandle
メソッドをまずは見ていきます。
このメソッドで行われていることは、(1)~(4)の条件のどれかをクリアしたリクエストは(5)へ進み、それ以外は例外処理が行われ、エラーが表示されるということです。そして、(1)~(4)の条件は何なのかというと、
(1) リクエストのHTTPメソッドがHEAD
、GET
、OPTIONS
であればtrue
、それ以外はfalse
(2) リクエストがユニットテスト内の物であればtrue
、それ以外はfalse
(3) セッションのチェックの対象外のリクエスト(App\Http\Middleware\VerifyCsrfToken
で設定できる)であればtrue
、それ以外はfalse
(4) リクエストで送られてきたデータの中の_token
の名前が与えられたデータとあらかじめ作成されたセッションデータが一致した場合true
、そうではない場合はfalse
(5)の内容は、リクエストで送られてきたセッションデータがある場合はそれをXSRF-TOKEN
という名前を付けたCookieへ書き込み、ない場合は新たにセッションのデータを作成し、それを書き込む感じです。これは処理を渡されると必ず実行され、その結果が次の処理へ渡されます。(理由は$this->shouldAddXsrfTokenCookie
メソッドはコードを書き換えない限り必ずtrue
を返すから)
先ほど見てきた(1)~(4)の条件の中で、セッションの確認を行っている(4)のメソッドについて次は見ていきます。
(4)を行っているメソッドはtokensMatch
メソッドで、コードは以下のようになっています。
protected function tokensMatch($request)
{
$token = $this->getTokenFromRequest($request);
return is_string($request->session()->token()) && is_string($token) &&
hash_equals($request->session()->token(), $token);
}
内容は非常にシンプルで、リクエストから送られてきた$token
とリクエストにあらかじめセットされた$request->session()->token()
が互いに文字列データであり一致している場合にtrue
を返します。(hash_equalsを用いることでタイミング攻撃に対して安全に文字列データを比較できます。また、$request->session()->token()
は後程あつかうIlluminate\Session\Store
インスタンスのtoken
メソッドの実行結果)
セッションの値の確認方法は分かりましたが、$request->session()->token()
の値と比較する対象である$token
がどのようにリクエストのデータから取得できるか謎なので、getTokenFromRequest
メソッドについて調べていきます。
protected function getTokenFromRequest($request)
{
$token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN'); //..(1)
if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header,
static::serialized()));
} //...(2)
return $token;
}
getTokenFromRequest
メソッドでは以下の二つのことが行われています。
(1) リクエストで送られてきた_token
の名前の付いたデータの値かそれが空であれば、X-CSRF-TOKEN
という名前のリクエストのヘッダーの値が代わりに入る。
(2) $token
の値がnull
かつX-XSRF-TOKEN
という名前のリクエストのヘッダーの値が存在し、null
ではない場合にX-XSRF-TOKEN
の値がうまく復号化できた場合にその値を$token
に入れる。
X-CSRF-TOKEN
とX-XSRF-TOKEN
のデフォルトの値はnull
なので、もしリクエストの_token
のデータが設定されていない場合はgetTokenFromRequest
メソッドはnull
を返し、その場合tokensMatch
メソッドの$request->session()->token()
と$token
一致しないため、tokensMatch
メソッドはfalse
を返します。こういうわけで@csrf
などを含めずにLaravelのPOSTメソッドを使ったリクエストを実行するとエラーが起こります。
LaravelのCSRF対策のためのセッションの比較の仕方は分かりましたが、セッションの生成の方法がまだ分かりません。そこで今度はデフォルト時のLaravelのセッションの生成の仕方を見てみます。
セッションが作られる過程を見てみる
StartSession
名前空間 Illuminate\Session\Middleware
StartSession
はIlluminate\Foundation\Http\kernel
クラスの$middlewarePriority
配列の要素の一つです。ちなみにこのkernel
クラスはpublic/index.php
を見てもらえれば分かると思うのですが、Laravelのシステム全体が動くときの起点となります。
では最初にhandle
メソッドについて見ていきます。
//初期設定
public function __construct(SessionManager $manager,
callable $cacheFactoryResolver = null)
{
$this->manager = $manager; //SessionManagerはここでセットされている
$this->cacheFactoryResolver = $cacheFactoryResolver;
}
public function handle($request, Closure $next)
{
if (! $this->sessionConfigured()) {
return $next($request);
} //...(1)
$session = $this->getSession($request); //...(2)
if ($this->manager->shouldBlock() ||
($request->route() instanceof Route && $request->route()->locksFor())) {
return $this->handleRequestWhileBlocking($request, $session, $next);
} else {
return $this->handleStatefulRequest($request, $session, $next);
} //...(3)
}
このプログラムの内容はこの3つです。
(1) もしconfig\session.php
が存在しない、もしくは中身がない場合はfalse
を返してクロージャの処理をするが、たいていのLaravelのアプリケーションの場合そんなことはないので、何もせずに次の処理へ
(2) セッションを取得する。詳しくは後で見ていく。
(3) もしconfig\session.php
内の配列のblock
キーの値がtrue
またはリクエストのURLの値からIlluminate\Routing\Route
インスタンスになるかつセッションごとにURLのリクエスト制限がない時に$this->handleRequestWhileBlocking($request, $session, $next)
は行われるが、それ以外は$this->handleStatefulRequest($request, $session, $next);
が実行される。
config\session.php
内の配列のblock
キーの値はたいてい設定のでfalse
であり、セッションごとにURLのリクエスト制限はデフォルトではありません。よってhandle
メソッドは大抵の場合$this->handleStatefulRequest($request, $session, $next);
を最終的に実行します。
全体の流れを見たところで、次はセッションの取得について見ていきます。
public function getSession(Request $request)
{
return tap($this->manager->driver(), function ($session) use ($request) {
$session->setId($request->cookies->get($session->getName()));
});
}
getSession
で行っていることは、セッションを扱うインスタンス(Illuminate\Session\Store
インスタンス、詳細は後で見ていく)をセットし、そのインスタンスへリクエストのCookieデータのうちconfig\session.php
内の配列のcookie
キーの値の名前が付けられたデータを代入します。
次はhandle
メソッドの処理の目玉であるhandleStatefulRequest
について見ていきます。
protected function handleStatefulRequest
(Request $request, $session, Closure $next)
{
$request->setLaravelSession($this->startSession($request, $session)); //...(1)
$this->collectGarbage($session); //...(2)
$response = $next($request); //...(3)
$this->storeCurrentUrl($request, $session); //...(4)
$this->addCookieToResponse($response, $session); //...(5)
$this->saveSession($request); //...(6)
return $response;
}
handleStatefulRequest
の内容はこの5つです。
(1) リクエストのCookieデータに従ってセッションを設定し、Illuminate\Session\Store
インスタンスをIlluminate\HttpRequest
インスタンスへセットする。これを行うことで、$request->session()
によりStore
インスタンスをいつでも呼び出せる。Store
インスタンスについては後で見ていく。
(2) 古くなったセッションデータ(config\session.php
内の配列のlifetime
キーの値を超えているデータ)を削除する。
(3) セッションと関りが浅いと思い全く調べていないので、処理の詳細は分からない。おそらくIlluminate\HttpResponse
インスタンスを返しているのかな?
(4) storeCurrentUrl
の処理の説明は必要なときのために現在のURLを保存すると書いてあるが、これがどれほど重要かはいまいちわからない。GETリクエスト以外では効果なし。
(5) レスポンスのヘッダーに、config\session.php
内の配列のcookie
キーの値の名前がついたCookieのデータをセットする。
(6) セッションのデータを保存する。保存場所・方法については後で見ていく。
(1)~(6)の処理が終わった後、レスポンスのインスタンスを返します。
ちなみにセッションをセットするstartSession
の処理はこんな感じです。
protected function startSession(Request $request, $session)
{
return tap($session, function ($session) use ($request) {
$session->setRequestOnHandler($request); //デフォルトの状態では無視してよし
$session->start();
});
}
Illuminate\Session\Store
インスタンスのstart
メソッドを実行しStore
インスタンスを返します。このstart
メソッドについては後で見ていきます。
これまではセッションの作成過程を大まかにみてきましたが、次はセッションの作成・管理を行うインスタンスがどのようにセットされているのかを見ていきます。
Manager(abstract class)
名前空間 Illuminate\Support
このManagerクラスは抽象クラスで、このクラス内にセッションを作成するなどの機能はなく、セッションや他のLaravelの機能を使用する際にそれぞれの機能を担うクラスたちをまとめる役割があります。
public function driver($driver = null)
{
$driver = $driver ?: $this->getDefaultDriver(); //...(1)
if (is_null($driver)) {
throw new InvalidArgumentException(sprintf(
'Unable to resolve NULL driver for [%s].', static::class));
} //...(2)
if (! isset($this->drivers[$driver])) {
$this->drivers[$driver] = $this->createDriver($driver);
} //...(3)
return $this->drivers[$driver];
}
このメソッドで行っていることは以下の三つです。
(1)このメソッドの引数にインスタンスが指定されていればそれを$driver
へ入れ、引数が指定されていない場合は、継承先のクラスに存在するgetDefaultDriver
メソッドの結果を入れる。デフォルトでは引数はnull
なため、getDefaultDriver
メソッドの結果が$driver
へ入れられる。getDefaultDriver
メソッドについては後で説明する。
(2)もし$driver
の値がnull
出会った時に例外処理が行われる。
(3)もし$this->drivers[$driver]
に値が既にある場合は何もせず、ない場合は$driver
の値をcreateDriver
メソッドへ渡し、その結果を$this->drivers[$driver]
へ入れる。
(1)~(4)が終了した後、$this->drivers[$driver]
の値を返します。
次にこのメソッドの(3)に登場したcreateDriver
メソッドについて見ていきます。
protected function createDriver($driver)
{
if (isset($this->customCreators[$driver])) {
return $this->callCustomCreator($driver); //...(1)
} else {
$method = 'create'.Str::studly($driver).'Driver'; //...(2)
if (method_exists($this, $method)) {
return $this->$method(); //...(3)
}
}
throw new InvalidArgumentException("Driver [$driver] not supported.");
}
このメソッドで行っていることは以下の三つです。
(1)もし$this->customCreators[$driver]
に値がセットされている場合はcallCustomCreator
メソッドの結果を返すが、あらかじめ$this->customCreators[$driver]
に値がセットされていることはないのでここでは扱わない。
(2)あらかじめ設定されている文字列とLaravelのhelperメソッドの一つであるStr::studlyメソッドの引数へ$driver
の値を入れるた結果の値を結合した値を$method
へ入れる。getDefaultDriver
メソッドを紹介する時に説明するが、$driver
の値は必ず文字列データになり、その値がStr::studlyメソッドによってそのデータの頭文字が大文字になる。
(3)method_existsのよってこのクラスまたは継承先のクラスに$method
の名前を持ったメソッドが存在する場合はその結果を返す。もし存在しない場合は例外処理が行われる。
次はManager
クラスの継承先の一つであり、getDefaultDriver
メソッドと$method
の名前を持ったメソッドを有しているSessionManager
クラスについて見ていきます。
SessionManager(Managerを継承)
名前空間 Illuminate\Session
public function getDefaultDriver()
{
return $this->config->get('session.driver');
}
このメソッドは継承元であるManagerクラスのcreateDriver
メソッドの引数である$driver
の値を作成してます。
このメソッドは/config/session.php
の配列内のdriver
要素の値を返します。この要素のデフォルトの値は'file'という文字データなので、createDriver
メソッドの引数である$driver
の値はデフォルトでは必ず文字列データになり、どうメソッド内の$method
の値はcreateFileDriver
になります。
先ほど見たように継承元であるManagerクラスのcreateDriver
メソッドは継承元かこのクラスの$method
の値のメソッドの結果を返します。さきほど$method
の値はcreateFileDriver
ということが分かったので、今度はこのクラス内のcreateFileDriver
メソッドについて見ていきます。
protected function createFileDriver()
{
return $this->createNativeDriver();
}
protected function createNativeDriver()
{
$lifetime = $this->config->get('session.lifetime'); //...(1)
return $this->buildSession(new FileSessionHandler(
$this->container->make('files'), $this->config->get('session.files'),
$lifetime)); //...(3)
}
createFileDriver
メソッドは同クラス内のcreateNativeDriver
メソッドの結果を返します。ということでcreateNativeDriver
メソッドについて見ていきます。
createNativeDriver
メソッドの内容は以下の二つです。
(1)/config/session.php
の配列内のlifetime
要素の値を$lifetime
へ入れる。(デフォルト値は120)
(2)$this->container->make('files')
で得られる、ファイル書き込み機能を実装したIlluminate\Filesystem\Filesystem
インスタンスと/config/session.php
の配列内のfiles
要素の値である/storage/framework/sessions/
までのパス名を引数として渡したセッションの値をファイルへ書きこむ機能を担うFileSessionHandler
インスタンスを同クラス内のbuildSession
メソッドへ渡し、その結果を返す。
buildSession
メソッドとはLaravelのセッションの様々な操作が書かれているクラスのインスタンスを返すメソッドなのですが、このメソッドについて掘り下げていきます。
protected function buildSession($handler)
{
return $this->config->get('session.encrypt')
? $this->buildEncryptedSession($handler)
: new Store($this->config->get('session.cookie'), $handler);
}
buildSession
メソッドは、/config/session.php
の配列内のencrypt
要素の値がfalse
の場合はセッションの操作を行うプログラムが書かれたIlluminate\Session\Store
クラスの引数に先ほどのFileSessionHandler
インスタンスを入れたインスタンスが返され、true
の場合はStore
クラスに$this->container['encrypter']
で得られるIlluminate\Encryption\Encrypter
クラスの機能が合わさったIlluminate\Session\EncryptedStore
インスタンスが返されます。デフォルトの場合のencrypt
要素の値はfalse
なのでStore
インスタンスが返されます。
いったんここまでの流れをまとめる(読み飛ばしても大丈夫)
ここまでCSRF対策を行うVerifyCsrfToken
クラスやStartSession
クラスのコードを見ながら、Laravel内のCSRF対策やセッションの作成過程を見てきましたが、セッションの作成やセッションの内容のファイルへの書きこみ、取り出しの過程をPHPでどのように実現させているのかをまだ見ていません。なので、次はトークンの作成と管理の機能がPHPのコードにより実装されているクラスを見ていくのですが、そのクラスの理解を容易にするためにセッションの作成過程が書かれているStartSession
のhandle
メソッドとhandleStatefulRequest
メソッドで行われることの流れとその結果をおさらいします。
handle
メソッドの流れとそれぞれの工程の結果はこんな感じでした。
- 送られてきたリクエストのCookieのからセッションのデータを取り出す。⇒
Illuminate\Session\Store
インスタンスのsetId
メソッドを実行したうえで、Store
インスタンスを呼び出す。 -
Illuminate\Http\Request
インスタンスとStore
インスタンスをhandleStatefulRequest
メソッドへ渡す。
handleStatefulRequest
メソッドの流れとそれぞれの工程の結果はこんな感じでした。
- 同クラス内の
startSession
メソッドでセッションを作成する。⇒Store
インスタンスのstart
メソッドを実行 - 期限切れのセッションを削除する。⇒
Store
インスタンスのgetHandler
メソッドを実行 - 作成したセッションを保存する。⇒
Store
インスタンスのsave
メソッドを実行
上の二つのメソッドの結果を見てわかる通り、セッションの作成にはIlluminate\Session\Store
インスタンスが密接にかかわっています。ということで次はStore
インスタンスの元であるIlluminate\Session\Store
クラスについて見ていきます。
Store
名前空間 Illuminate\Session
//初期設定
public function __construct($name, SessionHandlerInterface $handler, $id = null)
{
$this->setId($id);
$this->name = $name;
$this->handler = $handler;
}
Store
クラスのコンストラクトメソッドは、StartSession
クラスのgetSession
メソッド実行時、ひいてはManager
クラスのdriver
メソッド実行時にStore
インスタンス作成した際に呼び出されます。その時の$name
の値は$this->config->get('session.cookie')
、$handler
の値はIlluminate\Session\FileSessionHandler
インスタンスとなります。(ここら辺はSessionManager
クラスの説明の中で書かいています。)引数$id
は指定されていないので、$this->id
にはランダムな文字列データが入れられますが、getSession
メソッドの実行中に$this->id
は書き換えられるので、気にしなくて良いです。
public function setId($id)
{
$this->id = $this->isValidId($id) ? $id : $this->generateSessionId();
}
public function isValidId($id)
{
return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
}
protected function generateSessionId()
{
return Str::random(40);
}
StartSession
クラスのgetSession
メソッドで呼び出されるこのクラス内のsetId
メソッドは、引数の値がセッションの形式に沿っているか確かめ、もし沿っているのであればその値を返し、ダメであれば新たにセッションIDを作成し、$this->id
へその値を入れます。
セッションの形式の確認は同クラス内のisVaildId
メソッドで行っており、このメソッドでやっていることは、文字列データかつ英数字かつ文字数が40文字であればtrue
、そうでなければfalse
を返します。(詳しくはis_string、ctype_alnum、strlenを見て下さい。)
セッションIDの作成は同クラス内のgenerateSessionId
メソッドで行っています。このメソッドの内容はただ単にStr::random(40)で作成された40時のランダムな文字列データを返します。ちなみにStr::random()
にはrandom_bytesが使われているようです。
public function start()
{
$this->loadSession(); //...(1)
if (! $this->has('_token')) {
$this->regenerateToken();
} //...(2)
return $this->started = true; //...(3)
}
StartSession
クラスのstartSession
メソッドで実行されるstart
メソッドで行われることは以下の三つです。
(1)セッション情報が書かれたファイルを取り出し、その値を$this->attributes
配列へ入れる。詳しくは後で見ていく。
(2)もし$this->attributes
配列に'_token'がキーとなる値が存在しない場合同クラス内のregenerateToken
メソッドを実行。regenerateToken
メソッドについては後で見ていく。
(3)最後に$this->started
にtrue
を入れる。$this->started
はセッションの様々な操作に使われているようだが、どのように使われているかまでは調べきれなかったのでわからない。
次はstart
メソッドで一番初めに実行されるloadSession
について見ていきます。
protected function loadSession()
{
$this->attributes = array_merge($this->attributes, $this->readFromHandler());
}
protected function readFromHandler()
{
if ($data = $this->handler->read($this->getId())) { //......(1)
$data = @unserialize($this->prepareForUnserialize($data)); //......(2)
if ($data !== false && ! is_null($data) && is_array($data)) {
return $data; //......(3)
}
}
return [];
}
public function getId()
{
return $this->id;
}
loadSession
メソッドでは、同クラス内のreadFromHandler
メソッドの結果を$this->attributes
へ加えています。そして、そのメソッド内で実行されているreadFromHandler
メソッドは以下の三つのことを行っています。
(1)同クラスのsetId
メソッドで$this->id
へセットされた値をgetId
メソッドで呼び出し、その値を引数にとりFileSessionHandler
インスタンスのread
メソッドが実行される。raed
メソッドによりもし$this->id
の値と同じ名前のファイルが存在する場合、そのファイルの内容を$data
へ入れて(2)以降を実行し、存在しなければ文字列データの空の値(''
)を$data
へ入れ、何もせず空の配列を返す。
(2)ファイルから取得したセッションの情報は作成時に保存用の表現としてシリアル化されているので、unserializeメソッドでPHPで扱える値に復元する。(prepareForUnserialize
メソッドはただ引数を返すだけ)
(3)もしデータがうまく復元されなかったり、null
であったり、文字列データではない場合は空の配列を返し、そうでなければ$data
の値をそのまま返す。
今度は、loadSession
メソッドが終了した後に行われるhas
メソッドについて見ていきます。
public function has($key)
{
return ! collect(is_array($key) ? $key : func_get_args())
->contains(function ($key)
{
return is_null($this->get($key));
});
}
public function get($key, $default = null)
{
return Arr::get($this->attributes, $key, $default);
}
has
メソッドではcollectメソッドを使用してIlluminate\Support\Collection
インスタンスを呼び出し、そのインスタンスのメソッドであるcontains
メソッドにより引数の$key
がキーとなる値が$this->attributes
にある場合はtrue
、ない場合はfalse
を返します。contains
メソッドの引数内で実行されているget
メソッドArr::getによって$this->attributes
に引数$key
の値をキーにとる値がある場合はその値を返し、ない場合は空の文字列データを返します。
次にもしhas
メソッドの結果がtrue
の場合(ブラウザのクッキーにセッションが存在しない状態でリクエストが来た場合)に行われるregenerateToken
メソッドとそのメソッド内で実行されるput
メソッドを見ていきます。
public function regenerateToken()
{
$this->put('_token', Str::random(40));
}
public function put($key, $value = null)
{
if (! is_array($key)) {
$key = [$key => $value];
}
foreach ($key as $arrayKey => $arrayValue) {
Arr::set($this->attributes, $arrayKey, $arrayValue);
}
}
regenerateToken
メソッドはgenerateSessionId
メソッドと同じようにランダムな文字列データを作成し、その値を同クラス内のput
メソッドで_token
をキーとして$this->attributes
にセットします。put
メソッドは引数の$key
をキーとし、$value
をその値としてArr::setにより$this->attributes
へセットするか、$key
自体が配列の場合はそれを$this->attributes
へセットします。
この次はhandleStatefulRequest
メソッドの最後に実行されるsave
メソッドを見ていきます。
public function save()
{
$this->ageFlashData();
$this->handler->write($this->getId(), $this->prepareForStorage(
serialize($this->attributes)));
$this->started = false;
}
このメソッドでは、FileSessionHandler
インスタンスのwrite
メソッドにより、リクエストのクッキーのセッションIDの値(なければ新たに作成)を名前にとるファイルを新たに作成し、それに$this->attributes
をserializeでシリアル化させた値を書き込みます。そして最後におそらくセッションの処理が終わったことを知らせるために$this->started = false
を行います。
そして、最後に後で説明するヘルパーメソッドであるcsrf_token
メソッドで実行されるtoken
メソッドについて説明していきます。
public function token()
{
return $this->get('_token');
}
このメソッドの説明は簡潔で、get
メソッドにより_token
をキーにとる値が$this->attributes
に存在すればその値を返し、なければ空の文字列データを返します。$this->attributes[_token]
が存在しないことはデフォルトではまずないので、何かしらの文字列データを返します。
Store
クラスの中でFileSessionHandler
インスタンスが何回か登場したので、今度はFileSessionHandler
クラスについて見ていきます。
FileSessionHandler
名前空間:Illuminate\Session
public function __construct(Filesystem $files, $path, $minutes)
{
$this->path = $path;
$this->files = $files;
$this->minutes = $minutes;
}
このクラスのコンストラクトメソッドはSessionManager
クラスのbuildSession
メソッドの中で実行されます。この時の引数$file
には$this->container->make('files')
(Illuminate\Filesystem\Filesystem
インスタンス), 引数$path
には$this->config->get('session.files')
(/config/session.php
のfile
要素の値)、引数$minutes
には$this->config->get('session.lifetime')
(/config/session.php
のlifetime
要素の値)が入ります。
次はStore
クラスのreadFromHandler
メソッド内で実行されるread
メソッドについて見ていきます。
public function read($sessionId)
{
if ($this->files->isFile($path = $this->path.'/'.$sessionId)) { //...(1)
if ($this->files->lastModified($path) >= Carbon::now()
->subMinutes($this->minutes)->getTimestamp()) { //...(2)
return $this->files->sharedGet($path); //...(3)
}
}
return '';
}
read
メソッドでは以下の三つのことを行っています。
(1)Illuminate\Filesystem\Filesystem
クラスのisFile
メソッドで$this->path.'/'.$sessionId
の値のパスとファイル名を持つファイルがないか検証し、ある場合は$path
へそのファイルのパスとファイル名を入れて次の処理へ進み、ない場合は空の文字列データを返す。
(2)Filesystem
クラスのlastModified
メソッドにより(1)で取得した情報からそのファイルが最後に書き込まれた時間を割り出し、もしその値がCarbon\Carbon
クラスのsubMinutes
メソッドなどを使用し、現在の時刻から/config/session.php
のlifetime
要素の値を引いた数以上だった場合は次の処理へ進み、そうでない場合は空の文字列データを返す。lastModified
メソッドとgetTimestamp
メソッド(こちらはおそらく)はint型のUnix タイムスタンプで時刻を返し、Unix タイムスタンプは時間が進むほど大きくなる。つまり、ここで行っていることを言い換えるとファイルの作成時刻とリクエストが発生した時刻の差分がlifetime
要素の値より小さい(新しい)場合は次の処理、大きい(古い)場合は空の文字列データを返す、ということになる。
(3)ここでは$path
を引数にとりFilesystem
クラスのsharedGet
メソッドを実行する。sharedGet
メソッドについては後で見ていく。
最後にStore
クラスのsave
メソッドで実行されるwrite
メソッドを見ていきます。
public function write($sessionId, $data)
{
$this->files->put($this->path.'/'.$sessionId, $data, true);
return true;
}
といってもこのメソッドは単に、$this->config->get('session.files')
の値と引数$sessionId
の値を組み合わせたものと引数$data
をFilesystem
クラスのput
メソッドへ渡し、true
を返すということを行います。put
メソッドについては後で見ていきます。
次はこのクラスでたびたび出てきたFilesystem
クラスについて見ていきます。
Filesystem
名前空間:Illuminate\Filesystem
public function isFile($file)
{
return is_file($file);
}
このメソッドはis_fileの結果を返します。もし引数の$file
が通常のファイルならtrue
を返し、ディレクトリなどそれ以外ならfalse
を返します。
public function lastModified($path)
{
return filemtime($path);
}
このメソッドはfilemtimeメソッドの結果を返します。引数$path
が最後に書き込まれた時刻をint型のUnix タイムスタンプで返します。失敗した時は警告が発生します。
public function sharedGet($path)
{
$contents = ''; //...(1)
$handle = fopen($path, 'rb'); //...(2)
if ($handle) { //...(3)
try {
if (flock($handle, LOCK_SH)) { //...(4)
clearstatcache(true, $path); //...(5)
$contents = fread($handle, $this->size($path) ?: 1); //...(6)
flock($handle, LOCK_UN); //...(7)
}
} finally {
fclose($handle); //...(8)
}
}
return $contents; //...(9)
}
このメソッドで行うことは以下の9つです。
(1)$contents
の初期化
(2)fopenメソッドにより、$path
のファイルのポインタリソースを返す。つまり、対象のファイル中の動作をする場所を返している。もっと知りたい方はこちら(PHPのポインタについてはまだ勉強不足なので、ここについて詳しく書けない。)ちなみにfopen
メソッドの第二引数は行う動作を決めるものであり、rb
はバイナリモードでファイルを読み込むという動作を表している。バイナリモードについては勉強不足で説明できない。(教えて頂けるとありがたいです。)
(3)fopen
メソッドは失敗した場合にfalse
を返すので、失敗した時だけ(9)へ進み、それ以外は次の処理へ進む。
(4)flockメソッドとは、第一引数のポインタリソースを持つファイルの状態の変更を止めたり、他のプログラムからの参照を止めることで競合するプログラムが存在してもアプリケーション全体を問題なく動作させる機能を持つ。ちなみに第二引数のLOCK_SH
によって、他のプログラムから同じファイルのデータを参照できるが、変更できなくさせている。もしぴんと来なければ、こちらの記事をどうぞ。
(5)clearstatcacheメソッドは第一引数をtrue
にすることで第二引数のファイルのキャッシュ値を削除する機能を持つ。データが頻繁に変化するファイルを扱うときに便利。
(6)freadでfopen
メソッドで参照するファイルのリソースポインタから同クラスのsize
メソッド内のfilesizeメソッドで入手したファイルのサイズ分(単位はバイト)の内容を読み込み、その結果を$contents
へ入れる。もしファイルサイズが読み取れなかった場合は1バイト分読み込む。
(7)ファイルの読み込みが終わった後、そのままでは他のプログラムが読み込んだファイルの変更ができないため第二引数をLOCK_UN
にしたflock
メソッドによって、ファイルを再び自由に変更可能にします。
(8)fopen
メソッドで参照したファイルのリソースポインタを閉じる動作としてfcloseメソッドを行う。ちなみにこのメソッドを書かなくてもfopen
メソッドで参照したファイルのリソースポインタは勝手に閉じられる。
(9)$contents
の値を返す。
ここで、なぜfile-get-contentsメソッドを使用せず、fread
メソッドを使用しているのか疑問に思う方もいるかと思います。なぜfread
メソッドを使用するのかの解答は(調べてなくて)分かっていませんが、おそらくバイナリセーフなfread
メソッドを使用することでNullバイト攻撃を防ぐためだと思います。バイナリセーフ、Nullバイト攻撃についてはこちらの記事をどうぞ。
最後にStore
クラスのsave
メソッドで使用されたput
メソッドを見ていきます。
public function put($path, $contents, $lock = false)
{
return file_put_contents($path, $contents, $lock ? LOCK_EX : 0);
}
このメソッドで行われていることは、file_put_contentsメソッドで引数$path
に書かれているファイルへ$contents
のデータを書き込みます。Store
クラスのsave
メソッドにより$lock=true
となるため、file_put_contents
メソッドは他のプログラムによる書き込むファイルの読み込みと変更を禁止する状態(これを排他ロックというらしい)で実行されます。file_put_contents
メソッドが成功した場合はファイルへ書き込んだデータの量(バイト数)が返され、失敗した場合はfalse
が返されます。
一番最後に$request->input('_token')
の値を作成するヘルパーメソッドのcsrf_token
メソッドを見ていきます。
csrf_token(helper関数)
ファイル場所 laravel/framework/src/Illuminate/Foundation/
if (! function_exists('csrf_token')) {
/**
* Get the CSRF token value.
* @return string
* @throws \RuntimeException
* /
function csrf_token()
{
$session = app('session');
if (isset($session)) {
return $session->token();
}
throw new RuntimeException('Application session store not set.');
}
}
このメソッドはもしsession
の解決でStore
インスタンスが得られた場合にそのインスタンスのtoken
メソッドの結果を返します。もし何も得られなかった場合は例外処理に映ります。token
メソッドについてはStore
クラスを見ていく中で取り上げているので、そちらを見て下さい。session
の解決がStore
インスタンスを返す根拠は明確に見つかったわけではありませんが、同じヘルパーメソッドのsession
メソッドのコードのPHPDocの返り値の部分が@return mixed|\Illuminate\Session\Store|\Illuminate\Session\SessionManager
になっていたのに対し、実際に返している値がapp('session')
だからです。ここについては今後もう少し調べていきます。 実際にapp('session')
をLaravelのヘルパ関数であるdd
メソッドをかけて解析したところ、\Illuminate\Session\SessionManager
インスタンスを返しました。
終わりに
ここまで見てくれてありがとうございます、そしてお疲れ様です。
今回のコードリーディングで、Laravelと一般的なコードでCSRF対策のために行っていることの根本は変わらないということが知れて良かったです。
しかし、詳しく調べる気力が出なかったので、この記事の中には「きっとこんな感じだろう」という憶測で書いてしまっている部分もあります。今後もLaravelのコードを読むことで、このような部分を潰していきたいと思います。もし気になる、疑問に思うところがあれば指摘してくださるとありがたいです。
今後もLaravel内で気になることがあれば、こんな風にコードリーディングをできたらなーと思います。
ちなみにこんな記事も書いてます。良かったらぜひ
Laravelのサービスコンテナのバインドと解決の仕組みが知りたい!
Laravelのsingletonメソッドの機能とその仕組みについて