👋

Laravel SanctumのSPA認証でつまづいたところのメモ

2022/06/15に公開

Laravel SanctumのSPA認証(セッションベースの認証)の実装でかなりつまづいたので、メモを残しておこうと思います。おかげで認証とセキュリティ周りがちょっと分かるようになりました。

結論

  • クロスオリジンのリクエストの場合、axiosはCSRFトークンをCookieから取り出してリクエストヘッダにつける処理を行わない
  • フロントエンドとAPIを同一サイトに置いてSameSiteクッキーで認証すれば、トークンを使わずに(ほぼ)CSRF対策ができる

正しいかどうか自信がないので、間違っているところがあれば教えていただけるとうれしいです。

つまづいたところ

ドキュメントや記事を参考にして、ローカルではSPA認証を実装することができたのですが、検証環境にデプロイするとログインができなくなりました。原因は2つありました。

  • セッションIDがSameSiteクッキーで返却されるため、APIとフロントが別サイトだとセッションIDを受け取れない
    • APIはVPS、フロントはS3にデプロイしていたため別サイトでした
  • 異なるオリジンへのリクエストの場合、axiosはCSRFトークンを付与する処理を行わない

セッションIDがSameSiteクッキーで返却される

LaravelのデフォルトはSameSite属性がlaxになっているため、クッキーを受け取ることができていませんでした。Networkのレスポンスヘッダを見るとブロックされていることが分かりました。

https://i.gyazo.com/65b7d6bc511d24131afdcd02d1d1f59a.png

「SameSite属性がLaxで、クロスサイトのレスポンスなのでブロックされました」と書かれています。クッキーのSameSite属性とは、異なるサイトへのクッキーの送受信を制限するためのものです。

SameSite属性はLax、Strict、Noneという3つの値をとります。Strictの場合はクロスサイトのクッキーの送信が完全に制限されます。Noneの場合は制限がかからず、Laxは安全なリクエストのみ送信されます。

異なるオリジンへのリクエストの場合axiosはCSRFトークンを付与しない

Sanctumのドキュメントには、SPA認証の手順について以下のように書かれています。

  • 最初に/sanctum/csrf-cookieにリクエストを送る
  • レスポンスのクッキーで受け取ったXSRF-TOKENを、リクエストヘッダのX-XSRF-TOKENに付与してリクエストを送る
    • axiosはこの処理を自動で行ってくれるので、特別な処理を書かなくてもよい

しかし、CSRFトークンの自動付与はAPIとSPAが同一オリジンの場合しか行ってくれません。なぜなら、axiosのCSRFトークンの設定箇所に以下のような条件分岐があるためです。

if (utils.isStandardBrowserEnv()) {
  // withCredentialsオプションがtrueで、
  // リクエスト元とリクエスト先が同一オリジンの場合のみ、クッキーからCSRFトークンを取得する
  var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
    cookies.read(config.xsrfCookieName) :
    undefined;

  if (xsrfValue) {
    requestHeaders[config.xsrfHeaderName] = xsrfValue;
  }
}

https://github.com/axios/axios/blob/c714cffa6c642e8e52bf1a3dfc91a63bef0f6a29/lib/adapters/xhr.js#L143-L155

つまり、Laravel Mixを使っていてSPAとAPIを同じサーバーで動かす場合はCSRFトークンは送信されますが、別オリジンにデプロイする場合はCSRFトークンが付与されません。

どうすればよいか

フロントエンドとAPIのドメインを同一サイトにし、SameSiteクッキーで認証すればよいです。例えば、フロントエンドとAPIのドメインを以下のようにします。

サービス ドメイン
フロントエンド example.com
API api.example.com

CSRFトークンを使った制限は行わないため、/sanctum/csrf-cookieへのリクエストは不要になります。また、APIのCSRFトークンを使った保護も無効化します。

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        'api/*'
    ];
}

本当にこれで大丈夫なのか?

考えてみれば当たり前だったのですが、SameSiteクッキーを使うとCSRFはほぼ防ぐことができます。なぜなら偽造サイトからAPIにリクエストを送る際に、(偽造サイトが別サイトであれば)セッションIDは送信されないからです。

問題は、例えばテナントごとにドメインが割り振られるようなSaaSの場合です。この場合だと、悪意のある人がevil.example.comというサイトを作ると、CSRF攻撃が可能になりそうです。ただ今回はこれには当てはまらなかったので、問題ないと判断しました。

感想

分からない単語がたくさん出てきて難しかったですが、今まで避けてきたことが少し分かってうれしかったです。

参考

GitHubで編集を提案

Discussion