ASP.NET Coreでクッキー認証+AntiForgery+JWTによるリモートサーバとの認証情報共有と400 Bad Request対策

# はじめに

この記事では以下のことが書かれている。

- ASP.NET Coreでクッキー認証を用いたユーザー認証を行う為の方法
- それにJWTを組み合わせてリモートサーバとユーザー認証情報を共有する方法
- それにAntiForgeryを組み合わせた場合に起こる問題(400 Bad Request)と解決方法

元々は、あるWebシステムの操作中に突然400 Bad Requestが起こり、以降の操作（POST）が全て失敗するようになる、という不具合が起きたことから調べた結果となる。

このシステムではクッキー認証を用いたユーザー認証を行っており、さらにリモートサーバとの認証情報の共有の為にJWTをブラウザで持ちまわることでステートレスな認証を実現している。その上で、CSRF対策としてAntiForgeryを利用しており、それが何らかの条件で400 Bad Requestを返すようになってしまう、という現象だった。

それらの前提技術から解説し、そもそもなぜ400 Bad Requestが発生したのか、という原因とその対策方法を解説する。

# クッキー認証を用いたユーザー認証

ASP.NET Coreにおいてユーザー認証を行う場合、例えばクッキー認証（CookieAuthentication）を使って比較的簡単に実現できる。
クッキー認証はステートレスな認証方法の為、セッションベースの認証とは違い、サーバのリソースを消費しない利点がある。

```mermaid
sequenceDiagram
    participant Browser as ブラウザ
    participant WebServer as Webサーバ
    Browser->>WebServer: ログイン要求（username, password）
    WebServer->>WebServer: username, passwordの検証
    WebServer->>WebServer: ユーザ情報（ClaimsPrincipal）を生成
    WebServer->>WebServer: HttpContext.SignInAsync(ユーザー情報)
    WebServer->>Browser: ユーザー情報を含む認証クッキーを設定するレスポンス
    Browser->>WebServer: 認証クッキーを含むリクエスト
    WebServer->>WebServer: クッキー内の認証情報を検証（有効期限と改ざんチェック）
    WebServer->>Browser: 要求されたリソースのレスポンス
```

```C#:ログイン処理
public async Task SignInAsync(string username, string password)
{
    // ユーザー認証情報を検証
    if (!ValidateUserCredentials(username, password))
    {
        throw new Exception("ユーザー名またはパスワードが無効です。");
    }

    // ユーザーに対する一連のクレームを作成
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.Name, username)
        // 必要に応じてさらにクレームを追加
    };

    // ClaimsIdentityとClaimsPrincipalを作成
    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    var principal = new ClaimsPrincipal(identity);

    // ユーザーをサインイン
    await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
}

private bool ValidateUserCredentials(string username, string password)
{
    // ここでユーザー検証ロジックを実装
    // 有効ならtrue、無効ならfalseを返す
    return false;
}

```

ここで`HttpContext.SingInAsync`に渡される`ClaimsPrincipal`（ユーザー情報）は、次の方法で取得可能となる（ASP.NETでは`ClaimsPrincipal.Current`で取得できたがASP.NET Coreで廃止された）。

- `ControllerBase.User`
- `HttpContext.User`

詳しくはこちらを参照。

https://learn.microsoft.com/ja-jp/aspnet/core/migration/claimsprincipal-current?view=aspnetcore-7.0

# Webサーバからリモートサーバへアクセスする場合の認証情報の引継ぎ（JWTを利用）

ここで、Webサーバ以外のリモートサーバを用意し、Webサーバからそのリモートサーバへもユーザー認証付きでアクセスしなければならなくなったとする。
Webサーバで既に認証されたユーザーの認証情報を何らかの方法でリモートサーバへ引き継ぐ必要がある。

OAuth2.0を使う方法等もあるが、ここではJWTを使って自己完結型の認証を行うとする。

JWTの中にはusernameを含めることができる為、先ほどのユーザー情報（ClaimsPrincipal）としてJWTをそのまま使用するという方法が考えられる（HTTPSを使ったりHttpOnlyにしたりなどの対策は別途必要）。

リモートサーバへのアクセスが必要になったら、認証クッキーからユーザー情報（ClaimsPrincipal）としてJWTを取り出し、それをリモートサーバへの送信ヘッダに付与すれば良い。

シーケンス図は以下のようになる（認証サーバの存在は省略している）。

```mermaid
sequenceDiagram
    participant Browser as ブラウザ
    participant WebServer as Webサーバ
    participant RemoteServer as リモートサーバ
    Browser->>WebServer: ログイン要求（username, password）
    WebServer->>WebServer: username, passwordの検証
    WebServer->>WebServer: JWTの生成
    WebServer->>WebServer: JWTを含むユーザ情報（ClaimsPrincipal）を生成
    WebServer->>WebServer: HttpContext.SignInAsync(ユーザ情報）
    WebServer->>Browser: JWTを含むCookieを返却
    Browser->>WebServer: 要求（JWTを含むCookie）
    WebServer->>WebServer: Cookie中のJWTを検証
    WebServer->>RemoteServer: 要求（JWT）
    RemoteServer->>RemoteServer: JWTの検証
    RemoteServer->>WebServer: 要求データの返却
    WebServer->>Browser: 要求データの返却
```

コードは次のような感じになる。

```C#:JWTをClaimsPrincipalとしてクッキー認証を行う
string jwtToken = /* JWTトークンの取得 */;

var claims = new List<Claim>
{
    new Claim("Token", jwtToken),
};

var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

await HttpContext.SignInAsync(
    CookieAuthenticationDefaults.AuthenticationScheme,
    new ClaimsPrincipal(claimsIdentity));

```

```C#:HTTPリクエストからJWTを取り出す
// User.Identityは、現在のユーザーのIdentity（ClaimsIdentity）を返します。
if (HttpContext.User.Identity is ClaimsIdentity identity)
{
    // FindFirstメソッドは、指定したタイプのClaimを最初に見つけた場所を返します。
    // 我々がJWTトークンを"Token"という名前のClaimに保存したので、ここでそれを取り出しています。
    var jwtClaim = identity.FindFirst("Token");

    if (jwtClaim != null)
    {
        string jwtToken = jwtClaim.Value;

        // ここでjwtTokenを使用して何かを行う...
    }
}
```

# AntiForgeryで起こる問題（400 Bad Request）

この時、同じシステムでAntiForgeryを併用していると、問題が起こる。
AntiForgeryとはXSRFによる攻撃を防ぐためのASP.NETの仕組みであり、次のように有効にできる。

```C#:AntiForgeryを有効にする
builder.Services.AddAntiforgery(options =>
{
    // Set Cookie properties using CookieBuilder properties†.
    options.FormFieldName = "AntiforgeryFieldname";
    options.HeaderName = "X-CSRF-TOKEN-HEADERNAME";
    options.SuppressXFrameOptionsHeader = false;
});
```

そして、各コントローラに```[AutoValidateAntiforgeryToken]```属性を付与したり、個々のメソッドに```[IgnoreAntiforgeryToken]```属性を指定して無効化したりできる。

AntiForgeryは簡単に言うと、リクエスト全てにXSRFトークンを付与し、その検証を行うことで他者のなりすましを防止する為の仕組みである。
この検証の仕組みが、先ほどのJWTトークンによるユーザ認証と不整合を引き起こす場合がある。

まずは問題が起こらないケースを示す。

```mermaid
sequenceDiagram
    participant Browser as ブラウザ
    participant WebServer as Webサーバ
    participant RemoteServer as 認証サーバ
    Browser->>WebServer: ログイン要求
    WebServer->>RemoteServer: ユーザ認証、JWT生成
    RemoteServer->>WebServer: 生成されたJWT
    WebServer->>WebServer: JWTをユーザ情報として認証クッキーに保存
    WebServer->>WebServer: 認証されたユーザ情報（=JWT）を使ってXSRFトークンを生成
    WebServer->>Browser: 要求データの返却（新しいXSRFトークン）
    Browser->>WebServer: 要求（JWTを含むCookie, XSRFトークン）
    WebServer->>WebServer: Cookie中のJWTを検証(OK)
    WebServer->>WebServer: XSRFトークンの検証(ユーザ情報が同じなので成功)
    WebServer->>WebServer: 要求の処理（省略）
    WebServer->>Browser: 要求データの返却（新しいXSRFトークン）
```

これが、JWTトークンが有効期限切れになってリフレッシュされた場合に問題が起こる。

一言でいうと、「JWTが有効期限切れになった時にJWTがリフレッシュされると、クッキー認証で用いているユーザー情報（ClaimsPrincipal）自体が（JWTをユーザー識別子に使っている為）変わってしまい、AntiForgeryの検証が失敗する（400 Bad Requestが返る）」という現象が起こる。

問題が起こるケースをシーケンス図で示す。

```mermaid
sequenceDiagram
    participant Browser as ブラウザ
    participant WebServer as Webサーバ
    participant RemoteServer as 認証サーバ
    Browser->>WebServer: ログイン要求
    WebServer->>RemoteServer: ユーザ認証、JWT生成
    RemoteServer->>WebServer: 生成されたJWT
    WebServer->>WebServer: JWTをユーザ識別子として認証クッキーに保存
    WebServer->>WebServer: 認証されたユーザ識別子（=JWT）を使ってXSRFトークンを生成
    WebServer->>Browser: 要求データの返却（新しいXSRFトークン）
    Browser->>WebServer: 要求（JWTを含むCookie, XSRFトークン）
    WebServer->>WebServer: Cookie中のJWTを検証(有効期限切れ)
    WebServer->>RemoteServer: JWTのリフレッシュ要求
    RemoteServer->>WebServer: 新しいJWT
    WebServer->>WebServer: 新しいJWTで認証クッキーを再生成
    WebServer->>WebServer: XSRFトークンの検証(ユーザー識別子が違うから失敗)
    WebServer->>Browser: 400 Bad Request
```

これを回避するには、クッキー認証に設定するユーザー情報（ClaimsPrincipal）としてJWTそのもの以外に、ユーザーを一意に識別する情報（ここではusername）を指定する。```ClaimType```として```ClaimTypes.NameIdentifer```を用いる。

```diff_c_sharp:JWTをClaimsPrincipalとしてクッキー認証を行う
string jwtToken = /* JWTトークンの取得 */;

var claims = new List<Claim>
{
    new Claim("Token", jwtToken),
+    new Claim(ClaimTypes.NameIdentifier, username),
};

var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

await HttpContext.SignInAsync(
    CookieAuthenticationDefaults.AuthenticationScheme,
    new ClaimsPrincipal(claimsIdentity));

```

上記の対応により、AntiForgeryはXSRFトークンの検証（トークンが正しいユーザー識別子を含んでいるか？）にユーザー情報（ClaimsPrincipal）全体ではなく、「ClaimTypes.Nameの情報」のみを使用するようになる。

よって、JWTのリフレッシュの際に認証クッキーを再生成しても、JWTは変わってもClaimTypes.Name（つまりusername）は変更されない為、XSRFトークンの検証は引き続き有効となる。

このあたりの設定について、明確にドキュメントに記されていないように思う（敢えていうなら[ここ](https://learn.microsoft.com/ja-jp/aspnet/mvc/overview/security/xsrfcsrf-prevention-in-aspnet-mvc-and-web-pages#_Scenarios_with_special)？）のだが、AntiForgeryのソースコードを読むと次のようになっている。

https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Antiforgery/src/Internal/DefaultClaimUidExtractor.cs#L57

```C#
                var subClaim = identity.FindFirst(
                    claim => string.Equals("sub", claim.Type, StringComparison.Ordinal));
                if (subClaim != null && !string.IsNullOrEmpty(subClaim.Value))
                {
                    return new string[]
                    {
                        subClaim.Type,
                        subClaim.Value,
                        subClaim.Issuer
                    };
                }

                var nameIdentifierClaim = identity.FindFirst(
                    claim => string.Equals(ClaimTypes.NameIdentifier, claim.Type, StringComparison.Ordinal));
                if (nameIdentifierClaim != null && !string.IsNullOrEmpty(nameIdentifierClaim.Value))
                {
                    return new string[]
                    {
                        nameIdentifierClaim.Type,
                        nameIdentifierClaim.Value,
                        nameIdentifierClaim.Issuer
                    };
                }

                var upnClaim = identity.FindFirst(
                    claim => string.Equals(ClaimTypes.Upn, claim.Type, StringComparison.Ordinal));
                if (upnClaim != null && !string.IsNullOrEmpty(upnClaim.Value))
                {
                    return new string[]
                    {
                        upnClaim.Type,
                        upnClaim.Value,
                        upnClaim.Issuer
                    };
                }
            }

            // We do not understand any of the ClaimsIdentity instances, fallback on serializing all claims in every claims Identity.
            var allClaims = new List<Claim>();
            for (var i = 0; i < identitiesList.Count; i++)
            {
                if (identitiesList[i].IsAuthenticated)
                {
                    allClaims.AddRange(identitiesList[i].Claims);
                }
            }

            if (allClaims.Count == 0)
            {
                // No authenticated identities containing claims found.
                return null;
            }
```

つまり、ユーザー情報（ClaimsPrincipal）中の中に"sub"、又はClaimTypes.NameIdentifier、又はClaimTypes.Upn という識別子のクレーム情報があった場合、ユーザー情報（ClaimsPrincipal）全体ではなくそのクレーム情報のみを「ユニークなユーザー識別子」として利用する、ということである。

先ほどの例では、ClaimTypes.NameIdentifierという識別子でクレーム情報を追加することで、AntiForgeryがユーザー情報（ClaimsPrincipal）全体を使ってしまう（つまりJWTも含めて同一性チェックを行ってしまう）ことを防いでいる。

# 注意事項
この方法でAntiForgeryの認証を「ユーザー識別子」で行うようにすると、次の問題点が生じることに注意が必要である。尚、この問題は特別なものというわけではなく、普通にクッキー認証でシステムを構築すれば（認証に用いるユーザー識別子を毎回変えていない限り）起こりうる問題だと思われる。

- **問題点：古いXSRFリクエストトークンがずっと有効になる**

通常では、XSRFリクエストトークンは次のような仕組みで検証される。
- XSRFクッキートークン（XSRFリクエストトークンと共にペアで送信されるクッキー）の署名を検証
- XSRFリクエストトークンの署名を検証
- 両者のセキュリティトークンが一致することを確認（ペアとなるクッキートークンから生成されたリクエストトークンであることの確認）
- トークン中のユーザー識別子がログイン中のユーザー識別子（HttpContext.User.Identity）と一致するか確認

この時、**XSRFクッキートークンは、同じユーザー識別子である間は、ブラウザが開かれている間ずっと消されずに再利用され続ける**ようだ。これはクッキー認証の再ログインが行われても関係がない。なぜならAntiForgeryの仕組みはログインの為の認証とは別のものだからである。但し、規定では、XSRFクッキートークンはブラウザが閉じられると消える。

このため、そこから生成されたXSRFリクエストトークンは、XSRFクッキートークンが残っている限りずっと有効になる。これを無効化するには、**例えばログイン画面の表示時に既存のXSRFクッキートークンを削除するようにする**などして、XSRFクッキートークンの再生成を促す対策が必要となる。

# まとめ

最初、特定の条件でAntiForgeryが400 Bad Requestを返してくるという現象の原因が特定できず、多くの時間を費やした結果、上記のことが判明した。
ネットでも「ちゃんとやっているのに400 Bad Requestになる」というトラブルが散見され、未解決のままになっているようなので、もし同様の事象に遭遇したら、一度「利用している認証システムにおいてユーザー情報（ClaimsPrincipal）をどのように生成しているか」「そのユーザー情報が何らかのタイミングでリフレッシュされていないか」を確認しても良いかもしれない。