LoginSignup
59
56

More than 1 year has passed since last update.

LaravelのCSRF対策の処理を実際のコードから見てみる

Last updated at Posted at 2020-12-22

初めに

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つです。

  1. フォーム入力画面を作成時にPHPの暗号を生成する関数(openssl_random_pseudo_bytes)を実行し、その結果をセッションへセットする。
  2. セッションへセットしたデータはhtmlの<form></form>内にtokenという名前でセットし、他の入力データと共にHTTPのPOSTリクエスト実行から結果を表示するプログラムへ送る。
  3. 入力画面から送られてきたデータのうちtokenの名前に入っているデータを取り出し、それと入力画面作成時にセッションへセットしたデータを比べ、同じであれば成功時の処理を、違えばそこで処理を終了する。
    図で表すとこのようになります。
    CSRF対策の紹介.jpg

次に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対策.jpg

前振りはここまでにして、さっそく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メソッドがHEADGETOPTIONSであれば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-TOKENX-XSRF-TOKENのデフォルトの値はnullなので、もしリクエストの_tokenのデータが設定されていない場合はgetTokenFromRequestメソッドはnullを返し、その場合tokensMatchメソッドの$request->session()->token()$token一致しないため、tokensMatchメソッドはfalseを返します。こういうわけで@csrfなどを含めずにLaravelのPOSTメソッドを使ったリクエストを実行するとエラーが起こります。

LaravelのCSRF対策のためのセッションの比較の仕方は分かりましたが、セッションの生成の方法がまだ分かりません。そこで今度はデフォルト時のLaravelのセッションの生成の仕方を見てみます。

セッションが作られる過程を見てみる

StartSession

名前空間 Illuminate\Session\Middleware

StartSessionIlluminate\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のコードにより実装されているクラスを見ていくのですが、そのクラスの理解を容易にするためにセッションの作成過程が書かれているStartSessionhandleメソッドと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_stringctype_alnumstrlenを見て下さい。)
セッション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->startedtrueを入れる。$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->attributesserializeでシリアル化させた値を書き込みます。そして最後におそらくセッションの処理が終わったことを知らせるために$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.phpfile要素の値)、引数$minutesには$this->config->get('session.lifetime')/config/session.phplifetime要素の値)が入ります。
次は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.phplifetime要素の値を引いた数以上だった場合は次の処理へ進み、そうでない場合は空の文字列データを返す。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の値を組み合わせたものと引数$dataFilesystemクラスの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)freadfopenメソッドで参照するファイルのリソースポインタから同クラスの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メソッドの機能とその仕組みについて

59
56
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
59
56