ASP.NET Core 上にホストされた Blazor WebAssembly アプリ + Cookie ベースの認証で、CSRF 対策

# はじめに

Blazor WebAssembly アプリで何かしらの認証機構を組み込む場合は、今時代ですと、トークンベース、すなわち、HttpClient でサーバー側に HTTP 要求を送信する際に、別途認証機構で入手した "トークン" (つまるところは文字列ですが) を HTTP 要求ヘッダに毎回添付する方式が採用されることと思います。

いっぽうで事情によっては、古典的な Coolie に認証情報を収録しておく Cookie ベースの認証方式を採用することもあるでしょう。

そのような Cookie ベースの認証方式を採用する場合は、**CSRF、つまり、クロスサイトリクエストフォージェリ攻撃に対する対策**を施しておくべきケースがほとんどかと思います。

では、サーバー側の実装に ASP.NET Core を使用した、Blazor WebAssembly アプリを、Cookie ベースの認証方式とともに実装する際、CSRF 対策はどのように実装することができるでしょうか?

いろいろやり方はあるようですが、以下に、自分が採用した実装例を紹介したいと思います。

# 基本方針 - ASP.NET Core 提供の CSRF 対策機構を活用する

まず基本方針として、CSRF 対策の中心は、ASP.NET Core で提供される機構をそのまま採用することとします。

ということで、如何にして ASP.NET Core 提供の CSRF 対策機構を、ASP.NET Core + Blazor WebAssembly の組み合わせのアプリに織り込んでいくか、という話になります。

# ASP.NET Core Web アプリ側 (サーバー側) の実装

## `IAntiforgery` サービスの DI への登録と構成

まずはサーバー側の実装を進めます。

ASP.NET Core Web アプリ側 (サーバー側) の `Startup` クラス中、`ConfigureServices()` メソッド内にて、ASP.NET Core 提供の `IAntiforgery` サービスを、DI機構に登録しておきます。

このとき、`IAntiforgery` サービスのオプション指定として、**HTTP 要求ヘッダとして付加する要求トークンの、その HTTP 要求ヘッダ名を指定**しておきます。
ここでは `"X-ANTIFORGERY-TOKEN"` としてみました。

```csharp:Startup.cs
class Startup {
  ...
  void ConfigureServices(IServiceCollection services) {
    ...
    // 👇 これを追加
    services.AddAntiforgery(options => {
      options.HeaderName = "X-ANTIFORGERY-TOKEN";
    });
    ...
```

## 既定のフォールバックドキュメントとして Razor Pages を作成

次に、既定のドキュメントを、`index.html` のような静的 HTML ファイルではなく、サーバー側で動的レンダリングされるページに変更します。
なぜサーバー側で動的レンダリングするページを使うかというと、そのページ内の C# コード実装にて、CSRF 対策の検証用のトークンを Cookie に含めて、ページコンテンツとともにブラウザに送り込みたいためです。

サーバー側での動的レンダリング機構としては、ASP.NET Core MVC View で構いません。
ですが今回は、より簡易に記述可能な ASP.NET Core Razor Pages (.cshtml) を使うことにします。

ということで、既存の `index.html` の内容をまるまるそのまま転記した、`_Host.cshtml` というファイル名の Razor Pages を、ASP.NET Core Web アプリ側 (サーバー側) プロジェクトの `Pages` サブフォルダに作成します。
(Razor Pages のファイル名は、必ずしも "_Host.cshtml" である必要はありませんが、慣例に倣って、このファイル名で作成することにします)

作成したら、ちゃんと Razor Pages として機能させるために、`@page "/"` の一行を `_Host.cshtml` ファイルのいちばん最初の行に書き足しておきます。

```html:_Host.cshtml
@page "/"
<!DOCTYPE html>
<html>
...
```

## Razor Pages で、検証トークンを Cookie に含める

さらに、前述のとおり、この Razor Pages 内で、CSRF 対策の検証トークンを Cookie に含めたいので、その目的で、ASP.NET Core 提供の CSRF 対策の中心となる、`IAntiforgery` サービスを、DI 機構で入手します。

具体的には `_Host.cshtml` ファイルの冒頭に、以下のように `@inject ...` 行を追加します。

```html:_Host.cshtml
@page "/"
@* 👇  冒頭に、この行を追加 *@
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
<!DOCTYPE html>
<html>
...
```

`IAntiforgery` サービスを入手したら、同サービスの `GetAndStoreTokens()` メソッドを使って、CSRF 検証トークンを Cookie に埋め込むよう、`_Host.cshtml` ファイルの末尾にコードブロックを追加して実装します。

```csharp:_Host.cshtml
@* 👇 末尾に、下記コードブロックを追加 *@
@{
  var tokens = this.Antiforgery.GetAndStoreTokens(this.HttpContext);
}
```

## Razor Pages で、要求トークンを (Blazor WebAssembly 側に届ける手段として) Cookie に含める

さてここまでで CSRF 対策の検証トークンが Cookie に含まれるようになりました。
さらに、("検証" トークンではなく) CSRF 対策の要求トークンを Blazor WebAssembly 側に伝達する必要があります。

この伝達手段は、これまた Cookie を使いたいと思います。

つまり、Blazor WebAssembly 側で、Cookie に収録されたCSRF 対策要求トークンを読み取り、以降のサーバー側への HttpClient を使った HTTP 要求送信時に、その CSRF 対策要求トークンを HTTP 要求ヘッダに追加して送信するように実装していきます。

CSRF 対策要求トークンを収める Cookie 中のエントリ名は、`"X-ANTIFORGERY-TOKEN"` としてみました。
なお、先に `IAntiforgery` サービスを DI 機構に登録する際に指定した、HTTP 要求ヘッダ名と同じ名前にしてしまいましたが、同じ名前であることに何の効果も意味もありません :)

実装コードは下記のとおり、先ほど追加した `_Host.cshtml` 末尾のコードブロックに書き足します。

```csharp:_Host.cshtml
@{
  var tokens = this.Antiforgery.GetAndStoreTokens(this.HttpContext);

  // 👇 以下を追加
  this.Response.Cookies.Append(
    key: "X-ANTIFORGERY-TOKEN", // 👈 Cookie 中のエントリ名
    value: tokens.RequestToken, // 👈 CSRF 対策要求トークン

    // 👇 その他の Cookie オプション指定。
    //    クライアント側で読み取るための Cookie なので
    //    HttpOnly は false を指定することに注意。
    //    SameSite 指定でファーストパーティのみに制約するのも忘れずに。
    options: new Microsoft.AspNetCore.Http.CookieOptions {
      SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict,
      HttpOnly = false
  });
}
```

## Razor Pages の動作に必要なサービスを DI 機構に登録する

以上で CSRF 対策の検証トークン/要求トークンの Cookie への埋め込みのための Razor Pages 実装ができました。

引き続き、この `_Host.cshtml` Razor Pages 実装が適切に動作するよう、さらにサーバー側実装を仕上げていきます。

ASP.NET Core Web アプリ側 (サーバー側) の `Startup` クラス中、`ConfigureServices()` メソッド内にて、Razor Pages の使用に必要なサービス登録を済ませておきます。

```csharp:Startup.cs
class Startup {
  ...
  void ConfigureServices(IServiceCollection services) {
    ...
    // 👇 これを追加 (もしまだこの行が無い場合)
    services.AddRazorPages();
    ...
```

## フォールバックドキュメントを (`index.html` から) `_Host.cshtml` に変更する

そして、まだこの段階では、既定のフォールバックドキュメントとしては `index.html` が使われるようになったままだと思います。
ですので、ASP.NET Core Web アプリ側 (サーバー側) の `Startup` クラス中、`Configure()` メソッド内にて、フォールバックページの指定を `_Host.cshtml` Razor Pages とするよう書き換えます。

```csharp:Startup.cs
class Startup {
  ...
  void Configure(IApplicationBuilder app, ...) {
    ...
    app.UseEndpoints(endpoints => {
      ...
      // 👇 ".MapFallbackToFile("index.html")" を下記に書き換え
      endpoints.MapFallbackToPage("/_Host");
    });
    ...
```

## HTTP 要求の処理時、CSRF 要求トークンを検証する

ここまでで、CSRF 対策のサーバー側での基本的な配線作業は完了です。

あとは HTTP 要求を処理する際の API サーバー側の実装コードで、必要に応じて CSRF 要求トークンを検証するようにすればよいです。

やり方は ASP.NET Core 標準の一般的なやり方に同じです。
例えば ASP.NET Core の MVC/API コントローラーによる実装であれば、以下のように、`AutoValidateAntiforgeryToken` 属性を、コントローラークラスやアクションメソッドなどに適宜付記すればよいでしょう。

```csharp:Controllers/*.cs
[ApiController]
[AutoValidateAntiforgeryToken] // 👈 この属性を追加
public class FooController : ControllerBase
{
  ...
```

以上で ASP.NET Core Web アプリ側 (サーバー側) の実装は完了です。

# クライアント側の実装

## Cookie 中の要求トークンの取り出し

さて、CSRF 対策の検証・要求トークンが Cookie に含まれるようになり、サーバー側では適宜 CSRF 要求トークンの検証が行なわれるようになりました。

クライアント側もこれに対応していきます。

まずは、何はともあれ、Cookie 内に収録された CSRF 要求トークンを Blazor WebAssembly 側で取り出します。

ところで、Blazor WebAssembly には、どうも、標準では Cookie にアクセスする機能が用意されていないようです。

そこで、JavaScript 呼び出しと組み合わせて、Cookie 中の CSRF 対策要求トークンを取り出すことにします。
(※Blazor WebAssembly にて Cookie を読み書きするための NuGet パッケージライブラリもあるようですので、それらを使ってもよいかもしれません。)

自分は以下のような TypeScript ソースコード (`helper.ts`) を書いてこれを JavaScript (`wwwroot/helper.js`) にコンパイルし、

```typescript:helper.ts
// "helper.ts" → コンパイルして "wwwroot/helper.js"
function getCookie(key: string): string {
  const entry = document.cookie
    .split(';')
    .map(keyvalue => keyvalue.trim().split('='))
    .filter(keyvalue => decodeURIComponent(keyvalue[0]) === key)
    .pop();
    if (typeof (entry) === 'undefined') return '';
    return decodeURIComponent(entry[1]);
}
```

フォールバックドキュメント `_Host.cshtml` 中でこの JavaScript ファイルを読み込むようにしました。

```html:_Host.cshtml
  ...
  <script src="_framework/blazor.webassembly.js"></script>
  <script src="helper.js"></script> <!-- 👈 この行を追加 -->
</body>
</html>
```

これで、Blazor WebAssembly 側 (C# 側) コードにて、JavaScript 呼び出し機構を用いて、`getCookie("X-ANTIFORGERY-TOKEN")` JavaScript 関数を実行すれば、戻り値として CSRF 要求トークン文字列が返ります。

## `HttpClient` で HTTP 要求送信する際のヘッダに要求トークンを追加する

これで、CSRF 要求トークンが手に入る算段がつきました。
次はこの CSRF 要求トークンを、`HttpClient` から HTTP 要求送信する際に、その HTTP 要求の要求ヘッダに追加するよう配線していきます。

これは、Blazor WebAssembly 側の DI 機構への各種サービス登録時、`HttpClient` ないしはそのファクトリサービスなども DI 機構へ登録するわけですが、このタイミングで、`HttpClient` オブジェクトを生成する処理の一環として、Cookie からの CSRF 対策要求トークンの取得と、HTTP 要求ヘッダへの追加の処理も行なうようにします。

例えば、Blazor WebAssembly 側の `Program.cs` にて、以下のような感じで `HttpClient` の DI 機構への登録が実装されているケースが多いと思いますが、

```csharp:Program.cs
// Blazor WebAssembly 側の "Program.cs"
static async Task Main(string[] args) {
  ...
  builder.Services.AddScoped(services => {
    var httpClient = new HttpClient { 
      BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
    };
    return httpClient;
  });
  ...
```

この実装を、以下の要領で書き換えます。

```csharp:Program.cs
// Blazor WebAssembly 側の "Program.cs"
static async Task Main(string[] args) {
  ...
  builder.Services.AddScoped(services => {
    var httpClient = new HttpClient { 
      BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
    };

    // 👇 ここで、生成した HttpClient を返却する前に、要求トークンをヘッダに追加する。

    // 👇 1. JavaScript 相互運用機構を DI 経由で取得
    //    ※ Blazor WebAssembly 版 ならではの、同期実行が可能な 
    //      IJSInProcessRuntime で取得する。
    var jsRuntime = services.GetService<IJSRuntime>() as IJSInProcessRuntime;

    // 👇 2. 先に実装した JavaScript 関数を呼び出して、
    //       Cookie に含まれた CSRF 対策要求トークンを取得。
    var token = jsRuntime.Invoke<string>("getCookie", "X-ANTIFORGERY-TOKEN");

    // 👇 3. HTTP 要求送信時の要求ヘッダに、取得した CSRF 対策要求トークンを追加。
    httpClient.DefaultRequestHeaders.Add("X-ANTIFORGERY-TOKEN", token);

    return httpClient;
  });
  ...
```

以上でクライアント側の実装もすべて完了です。

これにて、Cookie ベースの認証方式を採用した ASP.NET Core ホスティングな Blazor WebAssembly でも、CSRF 対策を施すことができました。

# まとめ

振り返ってみると、C# のみならず (少しとは言え) JavaScript も書くこととなり、結構なコードを書く羽目になってしまいました。

本当はこんなに自分でゴリゴリとコードを書く必要はなくて、ASP.NET Core および Blazor 側で、もっと簡易に実装できる機構が用意されていたりするのでしょうか?

今のところ、自分は、今回紹介した実装で乗り切っていますが、もしかしたら悪手なのかもしれません。
お気づきの点はコメント等でお知らせいただけると多くの人が救われると思いますので、よろしくお願いします。

以上、_Learn, Practice, Share!_