WebSurfer's Home

Filter by APML

ASP.NET Core Identity の SecurityStamp

by WebSurfer 8. November 2024 18:06

ASP.NET Core Identity の SecurityStamp についていろいろ調べましたので、調べたことを備忘録として書いておきます。

どういう機能かを簡単に書くと、複数のユーザーが同じアカウントで同時にログインしている場合に、あるユーザーがパスワードの変更など重要なセキュリティに関わる変更を行った場合、他のログインユーザーの次回のアクセス時にサインアウトさせるというもので、デフォルトで有効に設定されています。

ASP.NET Core Identity が使うデータベースの AspNetUsers テーブルの SecuriryStamp フィールドと、認証クッキーに含まれる SecuriryStamp の値を使って上記の機能を実現しています。

下の画像はデータベースの AspNetUsers テーブルの SecuriryStamp 列の各ユーザーの値を SQL Server Management Studio で見たものです。

AspNetUsers テーブルの SecurityStamp 列

ユーザーがログインすると認証クッキーが発行されますが、その際データベースの SecurityStamp の値を Claim として認証クッキーに含めます。下の画像の赤枠部分を見てください。上の画像のデータベースの SecurityStamp の値と同じになっています。

認証クッキーに含まれる 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
    };
});

上のコードで行っているのは以下の通りです。

CookieAuthenticationOptionsEvents プロパティ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) を設定

ということで、

  1. ユーザーが認証クッキーを持ってアクセスしてくる
  2. cookie authentication プロセスが行われる
  3. CookieAuthenticationEvents が種々イベントをサブスクライブしている
  4. ValidatePrincipal イベントが発生すると ValidatePrincipalAsync が invoke される
  5. cookie と DB の SecurityStamp が異なっているとユーザーをサインアウトさせる

・・・というプロセスになるようです。

ValidatePrincipal イベントが発生するたび ValidatePrincipalAsync が無条件に呼ばれるようですが、その際上のステップ 5 を行うか否かにインターバルが関係しています。

そのあたりの詳細はググって調べてヒットするドキュメントでは分からなかったので Copilot に聞いてみたところ以下の回答でした。要するにミドルウェアが良しなにやっているとのことです。

  1. Initial Login: When the user logs in, an authentication ticket with the security stamp is issued and stored in the authentication cookie.
  2. Request Handling: When a request comes in, the authentication middleware reads the cookie and the authentication ticket.
  3. 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.
  4. 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 への影響が不明です。調べる気力がなくなったのでまだ未確認ですが、分かったら追記します。

Tags: , ,

Authentication

ASP.NET Core Web API と Swagger(その2)

by WebSurfer 23. October 2024 18:06

Visual Studio 2022 のテンプレートを使ってターゲットフレームワーク .NET 8.0 で ASP.NET Core Web API のプロジェクトを作成し、Visual Studio から実行すると下の画像のようにブラウザ上に swagger/index.html というページが表示され、そこから Web API のアクションメソッドを要求して応答を調べることができます。(追記: 2024/11/25 時点で、ターゲットフレームワーク .NET 9.0 で作成した Web API プロジェクトには Swagger は含まれません)

Swagger

Swagger を使って、(1) ファイルをアップロードする方法、及び (2) ベアラトークンを要求ヘッダに含めて送信する方法を調べましたので、備忘録として残しておくことにしました。

先の記事「ASP.NET Core Web API と Swagger(その1)」では (1) について書きました。この記事では (2) について書きます。

(2) ベアラトークンを要求ヘッダに含める

これについてはネットで検索して見つけた記事 OAuth Bearer Token with Swagger UI — .NET 6.0 が参考になりました。

テンプレートを使って作った ASP.NET Core Web API のプロジェクトの Program.cs に含まれている AddSwaggerGen メソッドを以下のように拡張します。コードに Use baerer token authorization header とコメントしてありますように、Type に SecuritySchemeType.Http を設定するのがポイントです。

//builder.Services.AddSwaggerGen();

builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo 
    { 
        Title = "My Web API", 
        Version = "v1" 
    });

    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Name = "Authorization",

        // Use baerer token authorization header
        Type = SecuritySchemeType.Http,

        Scheme = "Bearer",
        BearerFormat = "JWT",
        In = ParameterLocation.Header,
        Description = "Please enter token",
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            new List<string>()
        }
    });
});

上の OpenApiSecurityRequirement クラスの初期化のコードが見慣れない形になっていますが、それについて説明しておきます。

OpenApiSecurityRequirement クラスは Dictionary<OpenApiSecurityScheme,IList<String>> を継承してます。なので、コレクション初期化子を使用してディクショナリを初期化するのと同様に new Dictionary<TKey, TValue> {{ key1, value 1}, { key2, value2 }} という形でキーと値のペアを Add しながら OpenApiSecurityRequirement クラスを初期化しています。

上のコードを実装すると、この記事の一番上の画像のように、ブラウザに表示される Swagger 画面の右上に赤枠で囲ったように[Authorize]ボタンが表示されるようになります。

それをクリックすると、下の画像のように Available authorizations ダイアログがポップアップ表示されるので、そのテキストボックスにトークンを入力して、ダイアログ上の[Authorize]ボタンをクリックすれば、以降は Swagger からの要求すべてにベアラトークンが要求ヘッダに含まれて送信されるようになります。

Available authorizations ダイアログ

下は Swagger からの要求を Fiddler でキャプチャした画像です。赤枠で示したように要求ヘッダにベアラトークンが含まれて送信されています。

要求ヘッダのベアラトークン

上の操作で設定したベアラトークンを送信しないようにするには、Swagger 画面右上の[Authorize]ボタンをクリックして Available authorizations ダイアログを表示し、[Logout]ボタンをクリックします。

ログアウト

Tags: , , , ,

DevelopmentTools

ASP.NET Core Web API と Swagger(その1)

by WebSurfer 22. October 2024 18:00

Visual Studio 2022 のテンプレートを使ってターゲットフレームワーク .NET 8.0 で ASP.NET Core Web API のプロジェクトを作成し、Visual Studio から実行すると下の画像のようにブラウザ上に swagger/index.html というページが表示され、そこから Web API のアクションメソッドを要求して応答を調べることができます。(追記: 2024/11/25 時点で、ターゲットフレームワーク .NET 9.0 で作成した Web API プロジェクトには Swagger は含まれません)

Swagger

Swagger を使って (1) ファイルをアップロードする方法、及び (2) ベアラトークンを要求ヘッダに含めて送信する方法を調べましたので、備忘録として残しておくことにしました。

(1) と (2) を一つの記事に書くと長くなりすぎるので、この記事では (1) を書いて、(2) は別の記事に「その2」として書くことにします。

(1) ファイルアップロード

アクションメソッドの引数に IFormFile 型の変数を含めれば、Swagger が自動的にそれを検出してアップロードするファイルの選択が可能になり、Swagger から Web API にファイルをアップロードできるようになります。

Swagger にファイル選択のための画面を表示するには、まず、[Try it out]ボタンをクリックします。

[Try it out]ボタンをクリック

[Try it out]ボタンをクリックすると、下のように、ブラウザが html の <input type="file" > を表示した時と同様なファイル選択を行うための画面が表示されます。

アップロードするファイルの選択

その画面でアップロードするファイルを選択したら、[Execute]ボタンをクリックすれば選択したファイルは multipart/form-data 形式でサーバーに送信され、アクションメソッドの引数に渡されます。

下の Fiddler による要求のキャプチャ画像を見てください。

Fiddler による要求のキャプチャ画像

アクションメソッドへの引数に IFormFile 型の変数を含める方法ですが、下のコード例の SampleA メソッドのように直接含めても、SampleB メソッドのようにモデル経由で含めても、Swagger が自動的にそれを検出してくれます。上の画像は SampleA のものですが、SampleB でも同様になります。

using Microsoft.AspNetCore.Mvc;

namespace WebApi2.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class UploadController : ControllerBase
    {
        [HttpPost("SampleA")]
        public IActionResult SampleA(IFormFile? postedFile, 
                                     [FromForm] string? customField)
        {
            if (postedFile == null || postedFile.Length == 0)
            {
                return Content("ファイルを受信できませんでした");
            }

            if (customField == null)
            {
                return Content("customField を受信できませんでした");
            }

            return Content($"ファイル: {postedFile.FileName}, " +
                $"customField: {customField} 受信");
        }

        [HttpPost("SampleB")]
        public IActionResult SampleB([FromForm] UploadModels model)
        {
            if (model.PostedFile == null || model.PostedFile.Length == 0)
            {
                return Content("ファイルを受信できませんでした");
            }

            if (model.CustomField == null)
            {
                return Content("customField を受信できませんでした");
            }

            return Content($"ファイル: {model.PostedFile.FileName}, " +
                $"customField: {model.CustomField} 受信");
        }
    }

    public class UploadModels
    {
        public string? CustomField { get; set; }
        public IFormFile? PostedFile { get; set; }
    }
}

この記事の本題とは関係ない話ですが、上のサンプルコードで、SampleA の引数の型、SampleB が使う UploadModels のプロパティの型が null 許容型となっているのには理由がありますのでそれも書いておきます。

ユーザーがファイルを選択しないまま / customFiled が空白のまま[Execute]ボタンをクリックすると、null をアクションメソッドの引数にバインドしようとします。なので、引数を null 許容型にしておかないと、バインド時にエラーとなって HTTP 400 Bad Request が応答として返され、アクションメソッドは実行されません。

上のコード例で言うと、if 文以下は実行されないので期待した応答("customField を受信できませんでした" とか "ファイルを受信できませんでした")は返ってこないということになります。

Tags: , , , ,

DevelopmentTools

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  December 2024  >>
MoTuWeThFrSaSu
2526272829301
2345678
9101112131415
16171819202122
23242526272829
303112345

View posts in large calendar