【Laravel】Laravel SanctumがCSRFトークンの検証で何やってるかをちゃんと理解する

この記事は Laravel Advent Calendar 2024 19日目の投稿です。

Laravel - Qiita Advent Calendar 2024 - Qiita
Calendar page for Qiita Advent Calendar 2024 regarding Laravel.

Laravel Sanctum が簡単な認証システムを提供してくれるのは良く知られているんですが、認証に関する面倒なことを考えずに済む程度には抽象化されてしまっていて、裏で何をやっているのかを考えたことがありませんでした。

今回はそんな Laravel Sanctum が CSRF トークンの検証で何をやっているのかを追っていこうと思います。

Laravel Sanctum のインストールについては公式ドキュメントに記載があります。詳細はそちらを御覧ください。

Laravel - The PHP Framework For Web Artisans
Laravel is a PHP web application framework with expressive, elegant syntax. We’ve already laid the foundation — freeing ...

Middleware

Sanctum インストール時に bootstrap/app.php に以下の記述を追加したと思います。

->withMiddleware(function (Middleware $middleware) {
    $middleware->statefulApi();
})

これは、特定のミドルウェアの有効化フラグを true にします。

/**
 * Indicate that Sanctum's frontend state middleware should be enabled.
 *
 * @return $this
 */
public function statefulApi()
{
    $this->statefulApi = true;

    return $this;
}

ここが true になっていると、api ミドルウェアグループを利用してるエンドポイントは EnsureFrontendRequestsAreStateful というミドルウェアを通るようになります。

/**
 * Get the middleware groups.
 *
 * @return array
 */
public function getMiddlewareGroups()
{
    $middleware = [
        'web' => array_values(array_filter([
            \Illuminate\Cookie\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
            $this->authenticatedSessions ? 'auth.session' : null,
        ])),
        'api' => array_values(array_filter([
            $this->statefulApi ? \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class : null,
            $this->apiLimiter ? 'throttle:'.$this->apiLimiter : null,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ])),
    ];
(省略)
}

EnsureFrontendRequestsAreStateful では複数のミドルウェアを通していますが、ほとんどが セッションにまつわるミドルウェアで web ミドルウェアグループで定義されているミドルウェアとほぼ同じです。

/**
 * Handle the incoming requests.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  callable  $next
 * @return \Illuminate\Http\Response
 */
public function handle($request, $next)
{
    $this->configureSecureCookieSessions();

    return (new Pipeline(app()))->send($request)->through(
        static::fromFrontend($request) ? $this->frontendMiddleware() : []
    )->then(function ($request) use ($next) {
        return $next($request);
    });
}

(省略)

/**
 * Get the middleware that should be applied to requests from the "frontend".
 *
 * @return array
 */
protected function frontendMiddleware()
{
    $middleware = array_values(array_filter(array_unique([
        config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class),
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        config('sanctum.middleware.validate_csrf_token', config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class)),
        config('sanctum.middleware.authenticate_session'),
    ])));

    array_unshift($middleware, function ($request, $next) {
        $request->attributes->set('sanctum', true);

        return $next($request);
    });

    return $middleware;
}

その中から今回見ていくのは、VerifyCsrfToken になります。

VerifyCsrfToken

では、VerifyCsrfToken を見ていきます。

CSRFトークンの検証

/**
 * Handle an incoming request.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @return mixed
 *
 * @throws \Illuminate\Session\TokenMismatchException
 */
public function handle($request, Closure $next)
{
    if (
        $this->isReading($request) ||
        $this->runningUnitTests() ||
        $this->inExceptArray($request) ||
        $this->tokensMatch($request)
    ) {
        return tap($next($request), function ($response) use ($request) {
            if ($this->shouldAddXsrfTokenCookie()) {
                $this->addCookieToResponse($request, $response);
            }
        });
    }

    throw new TokenMismatchException('CSRF token mismatch.');
}

VerifyCsrfToken では、まず検証の対象外かどうかを判定するために以下を走査しています。

  • HTTP リクエストのメソッドが HEAD, GET, OPTIONS のいずれかかどうか
  • コンソールで実行されているかどうか
    • APP_RUNNING_IN_CONSOLE
    • SAPI が cli または phpdbg
  • ユニットテストで実行されているかどうか
    • APP_ENV が testing
  • 除外対象 URI かどうか

検証の対象だった場合は、リクエストされた CSRF トークンの整合性を検証します。

/**
 * Determine if the session and input CSRF tokens match.
 *
 * @param  \Illuminate\Http\Request  $request
 * @return bool
 */
protected function tokensMatch($request)
{
    $token = $this->getTokenFromRequest($request);

    return is_string($request->session()->token()) &&
            is_string($token) &&
            hash_equals($request->session()->token(), $token);
}

ここでは、まずリクエストからトークンを取得します。フォームからのリクエストなどでボディに、_token があればそれを利用し、なければヘッダから X-CSRF-TOKEN または X-XSRF-TOKEN を取得します。
そして、そのトークンとサーバー側のセッションストレージに保存されているトークンを比較しています。この検証に失敗すると、TokenMismatchException が投げられ、ステータスコード 419 でレスポンスが返される、ということになります。

CSRFトークンの付与

CSRF トークンは Set-Cookie レスポンスヘッダで付与されます。これも VerifyCsrfToken でやっています。

/**
 * Handle an incoming request.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @return mixed
 *
 * @throws \Illuminate\Session\TokenMismatchException
 */
public function handle($request, Closure $next)
{
    if (
        $this->isReading($request) ||
        $this->runningUnitTests() ||
        $this->inExceptArray($request) ||
        $this->tokensMatch($request)
    ) {
        return tap($next($request), function ($response) use ($request) {
            if ($this->shouldAddXsrfTokenCookie()) {
                $this->addCookieToResponse($request, $response);
            }
        });
    }

    throw new TokenMismatchException('CSRF token mismatch.');
}

(省略)

/**
 * Add the CSRF token to the response cookies.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Symfony\Component\HttpFoundation\Response  $response
 * @return \Symfony\Component\HttpFoundation\Response
 */
protected function addCookieToResponse($request, $response)
{
    $config = config('session');

    if ($response instanceof Responsable) {
        $response = $response->toResponse($request);
    }

    $response->headers->setCookie($this->newCookie($request, $config));

    return $response;
}

(省略)

/**
 * Create a new "XSRF-TOKEN" cookie that contains the CSRF token.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  array  $config
 * @return \Symfony\Component\HttpFoundation\Cookie
 */
protected function newCookie($request, $config)
{
    return new Cookie(
        'XSRF-TOKEN',
        $request->session()->token(),
        $this->availableAt(60 * $config['lifetime']),
        $config['path'],
        $config['domain'],
        $config['secure'],
        false,
        false,
        $config['same_site'] ?? null,
        $config['partitioned'] ?? false
    );
}

ここでは、XSRF-TOKEN でセッショントークンを返すようにしています。

まとめ

  • Laravel Sanctum インストールの手順を行うと、api ミドルウェアグループでセッションの検証ができるようになる
  • CSRF トークンの検証は、VerifyCsrfToken ミドルウェアで行っている
    • 対象でなければ検証しない
    • 対象であればトークンとセッションデータを比較する
  • CSRF トークンは Set-Cookie レスポンスヘッダに XSRF-TOKEN を付与している
タイトルとURLをコピーしました