この記事は Laravel Advent Calendar 2024 19日目の投稿です。
Laravel Sanctum が簡単な認証システムを提供してくれるのは良く知られているんですが、認証に関する面倒なことを考えずに済む程度には抽象化されてしまっていて、裏で何をやっているのかを考えたことがありませんでした。
今回はそんな Laravel Sanctum が CSRF トークンの検証で何をやっているのかを追っていこうと思います。
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
を付与している