はじめに
この記事では以下のことが書かれている。
- 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)を使って比較的簡単に実現できる。
クッキー認証はステートレスな認証方法の為、セッションベースの認証とは違い、サーバのリソースを消費しない利点がある。
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
詳しくはこちらを参照。
Webサーバからリモートサーバへアクセスする場合の認証情報の引継ぎ(JWTを利用)
ここで、Webサーバ以外のリモートサーバを用意し、Webサーバからそのリモートサーバへもユーザー認証付きでアクセスしなければならなくなったとする。
Webサーバで既に認証されたユーザーの認証情報を何らかの方法でリモートサーバへ引き継ぐ必要がある。
OAuth2.0を使う方法等もあるが、ここではJWTを使って自己完結型の認証を行うとする。
JWTの中にはusernameを含めることができる為、先ほどのユーザー情報(ClaimsPrincipal)としてJWTをそのまま使用するという方法が考えられる(HTTPSを使ったりHttpOnlyにしたりなどの対策は別途必要)。
リモートサーバへのアクセスが必要になったら、認証クッキーからユーザー情報(ClaimsPrincipal)としてJWTを取り出し、それをリモートサーバへの送信ヘッダに付与すれば良い。
シーケンス図は以下のようになる(認証サーバの存在は省略している)。
コードは次のような感じになる。
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));
// 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の仕組みであり、次のように有効にできる。
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トークンによるユーザ認証と不整合を引き起こす場合がある。
まずは問題が起こらないケースを示す。
これが、JWTトークンが有効期限切れになってリフレッシュされた場合に問題が起こる。
一言でいうと、「JWTが有効期限切れになった時にJWTがリフレッシュされると、クッキー認証で用いているユーザー情報(ClaimsPrincipal)自体が(JWTをユーザー識別子に使っている為)変わってしまい、AntiForgeryの検証が失敗する(400 Bad Requestが返る)」という現象が起こる。
問題が起こるケースをシーケンス図で示す。
これを回避するには、クッキー認証に設定するユーザー情報(ClaimsPrincipal)としてJWTそのもの以外に、ユーザーを一意に識別する情報(ここではusername)を指定する。ClaimType
としてClaimTypes.NameIdentifer
を用いる。
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トークンの検証は引き続き有効となる。
このあたりの設定について、明確にドキュメントに記されていないように思う(敢えていうならここ?)のだが、AntiForgeryのソースコードを読むと次のようになっている。
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が400 Bad Requestを返してくるという現象の原因が特定できず、多くの時間を費やした結果、上記のことが判明した。
ネットでも「ちゃんとやっているのに400 Bad Requestになる」というトラブルが散見され、未解決のままになっているようなので、もし同様の事象に遭遇したら、一度「利用している認証システムにおいてユーザー情報(ClaimsPrincipal)をどのように生成しているか」「そのユーザー情報が何らかのタイミングでリフレッシュされていないか」を確認しても良いかもしれない。