ASP.NET Core Identity の SecurityStamp についていろいろ調べましたので、調べたことを備忘録として書いておきます。
どういう機能かを簡単に書くと、複数のユーザーが同じアカウントで同時にログインしている場合に、あるユーザーがパスワードの変更など重要なセキュリティに関わる変更を行った場合、他のログインユーザーの次回のアクセス時にサインアウトさせるというもので、デフォルトで有効に設定されています。
ASP.NET Core Identity が使うデータベースの AspNetUsers テーブルの SecuriryStamp フィールドと、認証クッキーに含まれる SecuriryStamp の値を使って上記の機能を実現しています。
下の画像はデータベースの AspNetUsers テーブルの SecuriryStamp 列の各ユーザーの値を SQL Server Management Studio で見たものです。
ユーザーがログインすると認証クッキーが発行されますが、その際データベースの SecurityStamp の値を Claim として認証クッキーに含めます。下の画像の赤枠部分を見てください。上の画像のデータベースの SecurityStamp の値と同じになっています。
一旦ユーザーがログインに成功すると、それ以降はそのユーザーがサイトにアクセスしてくるたび認証クッキーが送信され、送られてきた認証クッキーをチェックして有効であればログインが継続されるという仕組みになっています。
ログインしたユーザーは自分のアカウントの設定を変更する管理画面にアクセスできます。下の画面は自分のパスワードを変更するページです。
上の画面でパスワードの変更に成功すると、同時にデータベースの SecuriryStamp の値が変更されます。
パスワードを変更した際、ユーザーには認証クッキーが再発行されます。それにより、変更後のデータベースの SecuriryStamp の値と再発行された認証クッキーに含まれる SecuriryStamp の値は同じになります。
一方、同じアカウントでログインしている他のユーザーには認証クッキーは再発行されませんので、認証クッキーに含まれる SecuriryStamp の値は古いまま、すなわちデータベースと認証クッキーの SecurityStamp とは異なった値になります。
なので、あるユーザーによるパスワード変更後、他のユーザーがアクセスして来た時にデータベースと認証クッキーの SecuriryStamp の値を比較すれば異なっているので、そのユーザーをサインアウトさせることが可能です。
ただし、比較するにはデータベースにクエリを投げてデータベースの SecuriryStamp の値を取得してくる必要があります。それをユーザーがアクセスしてくるたびに行うのはサーバーの負担が大きいという問題があります。それゆえインターバル(デフォルトで 30 分)を設けています。
例えば 10,000 人のユーザーが同時にログインしていて、一人当たり 1 分に 5 回アクセスしてくるとします。インターバルを設けないでアクセスのたび毎回 SecurityStamp の比較を行うとすると、1 分間に 50,000 回データベースにクエリを投げることになります。
インターバルを 1 分にすると、ユーザー当たり 1 分に 5 回のリクエストの内 4 回は検証しないので 1/5 に、すなわち全体では 10,000 回/分に減ります。
さらに、インターバルを 30 分に増やすと 1/150 に、すなわち全体では 333 回/分に減ります。その程度であれば問題ないであろうということでデフォルトが 30 分になっているようです。
ただし、インターバルが短いほどセキュリティは高くなるはずなので、ユーザーが少なくアクセスの少ないサイトであれば短くした方が良さそうです。
Program.cs に以下のコードを追加することによりインターバルを変更できます。
// インターバルは FromMinutes(m) の m で設定。下のコードは 30 分
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
options.ValidationInterval = TimeSpan.FromMinutes(30));
詳しくは Microsoft のドキュメント「ISecurityStampValidator とすべてからのサインアウト」に書いてありますので見てください。
以上で分かった気になっていたのですが、よく考えてみると「チェックを 30 分のインターバルで行うとして、そのタイミングと、ユーザーがアクセスするタイミングは当然異なるはずだが、そこはどうしているのか?」が疑問です。そのあたりをさらに調べてみました。
上に紹介したMicrosoft のドキュメントには "Identity の既定の実装では、SecurityStampValidator がメインのアプリケーション cookie と 2 要素 cookie に登録されます。検証コントロールは、各 cookie の OnValidatePrincipal イベントにフックして Identity を呼び出し、ユーザーのセキュリティ スタンプ クレームが cookie に格納されているものから変更されていないことを確認します" と書いてあります。
具体的には、「メインのアプリケーション cookie」の方でいうと、そのために以下のオプション設定がデフォルトでされているということのようです。
builder.Services.ConfigureApplicationCookie(options =>
{
// cookie 設定
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
};
});
上のコードで行っているのは以下の通りです。
CookieAuthenticationOptions の Events プロパティに CookieAuthenticationEvents (Allows subscribing to events raised during cookie authentication) を設定。
CookieAuthenticationEvents の OnValidatePrincipal プロパティ (Invoked to validate the principal) に SecurityStampValidator クラス の ValidatePrincipalAsync 静的メソッド (Validates a principal against a user's stored security stamp) を設定
ということで、
- ユーザーが認証クッキーを持ってアクセスしてくる
- cookie authentication プロセスが行われる
- CookieAuthenticationEvents が種々イベントをサブスクライブしている
- ValidatePrincipal イベントが発生すると ValidatePrincipalAsync が invoke される
- cookie と DB の SecurityStamp が異なっているとユーザーをサインアウトさせる
・・・というプロセスになるようです。
ValidatePrincipal イベントが発生するたび ValidatePrincipalAsync が無条件に呼ばれるようですが、その際上のステップ 5 を行うか否かにインターバルが関係しています。
そのあたりの詳細はググって調べてヒットするドキュメントでは分からなかったので Copilot に聞いてみたところ以下の回答でした。要するにミドルウェアが良しなにやっているとのことです。
-
Initial Login: When the user logs in, an authentication ticket with the security stamp is issued and stored in the authentication cookie.
-
Request Handling: When a request comes in, the authentication middleware reads the cookie and the authentication ticket.
-
Interval Check: The middleware checks if the time since the last validation exceeds the ValidationInterval. This check is done based on the current time and the internally tracked last validation timestamp.
-
Update: If the validation occurs, the middleware updates its internal record of the last validation time.
AI の回答は間違っていることもあるので、裏を取るため SecurityStampValidator.cs の ValidatePrincipalAsync メソッドが呼び出す ValidateAsync メソッドのコードを調べてました。Copilot の言っていることに間違いはなさそうです。
参考に SecurityStampValidator.cs の ValidateAsync メソッドのコードをコピーして以下に載せておきます。
public virtual async Task ValidateAsync(CookieValidatePrincipalContext context)
{
var currentUtc = TimeProvider.GetUtcNow();
var issuedUtc = context.Properties.IssuedUtc;
// Only validate if enough time has elapsed
var validate = (issuedUtc == null);
if (issuedUtc != null)
{
var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
validate = timeElapsed > Options.ValidationInterval;
}
if (validate)
{
var user = await VerifySecurityStamp(context.Principal);
if (user != null)
{
await SecurityStampVerified(user, context);
}
else
{
Logger.LogDebug(EventIds.SecurityStampValidationFailed,
"Security stamp validation failed, rejecting cookie.");
context.RejectPrincipal();
await SignInManager.SignOutAsync();
await SignInManager.Context.SignOutAsync(
IdentityConstants.TwoFactorRememberMeScheme);
}
}
}
その他気が付いたことも書いておきます。
上に紹介した Microsoft のドキュメントに書いてありますが、データベースの SecurityStamp を変更するには userManager.UpdateSecurityStampAsync(user) を呼び出します。
上に書いた管理画面でのパスワード変更にはそのメソッドが実装されているようで、パスワード変更操作でデータベースの SecurityStamp が変更されます。
ユーザーにアサインされるロールが変更された場合もデータベースの SecurityStamp を変更した方が良さそうですが、自分のブログの記事「ASP.NET Identity のロール管理 (CORE)」に書いた自作のメソッド EditRoleAssignment の AddToRoleAsync, RemoveFromRoleAsync ではデータベースの SecurityStamp は変わりません。SecurityStamp を変更するには userManager.UpdateSecurityStampAsync(user) の呼び出しを追加で実装する必要があります。
また、Microsoft のドキュメントに書いてあった sign out everywhere については、Logout.cshtml.cs に以下のように追加すれば実現できます。
public async Task<IActionResult> OnPost(string returnUrl = null)
{
// ログアウトで SecurityStamp を再生成するため追加
var user = await _userManager.GetUserAsync(User);
await _userManager.UpdateSecurityStampAsync(user);
await _signInManager.SignOutAsync();
_logger.LogInformation("User logged out.");
if (returnUrl != null)
{
return LocalRedirect(returnUrl);
}
else
{
// This needs to be a redirect so that the browser performs a new
// request and the identity for the user gets updated.
return RedirectToPage();
}
}
もう一つ気になっていることがあります。それはデフォルトで有効になっている SlidingExpiration の影響です。これが働くと有効期間が延長された認証クッキーが再発行されますが、それによる SecurityStamp への影響が不明です。調べる気力がなくなったのでまだ未確認ですが、分かったら追記します。