WebSurfer's Home

Filter by APML

JWT からクレーム情報を取得

by WebSurfer 20. August 2024 16:47

トークンベース (JWT) の認証を実装した ASP.NET Core Web API アプリで、クライアントから送信されてきた JWT から、ユーザー名やトークンの有効期限などの情報を取得する方法を書きます。

デコードされた JWT

実は最近知ったのですが、先の記事「ASP.NET Core Web API に Role ベースの承認を追加」に書いた JWT による認証を実装した ASP.NET Core Web API アプリでは、上の画像の JWT の Payload 部分は ASP.NET Core で言うクレームのコレクションとして取り扱われるようです。

具体的に言うと、JWT の Payload の項目から ClaimsIdentity オブジェクトが作られ、コントローラーに使われる ControllerBase クラスの User プロパティで取得できる ClaimsPrincipal オブジェクトに ClaimsIdentity が含まれるようになります。

なので、上の画像のように Payload に Role を追加すれば、先の記事に書いたように [Authorize(Roles = "Admin")] 属性によって Web API 側でアクセス制限ができるようになります。

さらに、ClaimsIdentity オブジェクトの各クレームの値は ClaimsPrincipal クラスの FindFirst(String) メソッドを使って取得できますので、必要があればサーバー側でユーザー名とかトークンの有効期限などを取得して、その内容に応じて何らかの処置を行うというようなことも可能になります。

以下に具体例を書いておきます。

まず、上の画像のように JWT の Payload に Role、UserId、exp を含める方法ですが、先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」で紹介したトークンを発行する API の BuildToken メソッドで、JwtSecurityToken コンストラクタの引数の claims に以下のようにロール情報と UserId を追加します。上の画像の JWT の Payload の "exp" は引数 expires に設定した UNIX 時間となります。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace WebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TokenController : ControllerBase
    {
        private readonly IConfiguration _config;
        private readonly UserManager<IdentityUser> _userManager;

        public TokenController(IConfiguration config,
                               UserManager<IdentityUser> userManager)
        {
            _config = config;
            _userManager = userManager;
        }

        [AllowAnonymous]
        [HttpPost]
        public async Task<IActionResult> CreateToken(LoginModel login)
        {
            string? id = login.Username;
            string? pw = login.Password;
            IActionResult response = Unauthorized();

            if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(pw))
            {
                var user = await _userManager.FindByNameAsync(id);
                if (user != null && 
                    await _userManager.CheckPasswordAsync(user, pw))
                {
                    // クライアントから送信されてきた id を UserId 
                    // として JWT の Payload に含める
                    var tokenString = BuildToken(id);

                    response = Ok(new { token = tokenString });
                }
            }

            return response;
        }

        private string BuildToken(string userId)
        {
            var key = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

            var creds = new SigningCredentials(
                key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: _config["Jwt:Issuer"],
                audience: _config["Jwt:Issuer"],

                // Role 情報と UserID を追加
                claims: [ 
                    new Claim(ClaimTypes.Role, "Admin"),
                    new Claim("UserId", userId)
                ],

                notBefore: null,
                expires: DateTime.Now.AddMinutes(30),
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }
    }

    public class LoginModel
    {
        public string? Username { get; set; }
        public string? Password { get; set; }
    }
}

Web API のコントローラーで、ControllerBase.User プロパティから取得できる ClaimsPrincipal オブジェクトの FindFirst(String) メソッドを使って、JWT の Payload の情報を取得できます。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

namespace WebApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        // ・・・中略・・・

        [Authorize(Roles = "Admin")]
        [HttpGet(Name = "GetWeatherForecast")]
        public IEnumerable<WeatherForecast> Get()
        {
            // 2024/8/20
            // JWT の Payload の情報は以下のようにして取得できる
            string? role = User.FindFirst(ClaimTypes.Role)?.Value;
            string? userId = User.FindFirst("UserId")?.Value;
            string? exp = User.FindFirst("exp")?.Value;  // UNIX 時間
            if (exp != null)
            {
                var ticks = long.Parse(exp) * 1000L * 1000L * 10L + 
                            DateTime.UnixEpoch.Ticks;
                var expDateTime = new DateTime(ticks, DateTimeKind.Utc);
                var dateTimeUtcNow = DateTime.UtcNow;
                bool isBeforeExp = expDateTime > dateTimeUtcNow;
            }

            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}

クライアントから上の画像の JWT を送信した結果は下の画像の通りとなります。Web API のコントローラーで上の画像の Payload の情報を取得できていることが分かりますでしょうか?

JWT の Payload 情報の取得結果



【オマケ】

本題とは直接関係ないことですが、Microsoft のドキュメント「ASP.NET Core でのクレーム ベースの承認」に ClaimsIdentity というのは何かを、運転免許証を例にとって説明してあって分かりやすかったので、忘れないように以下に抜粋を貼っておきます。

"運転免許証にはあなたの生年月日が記載されています。 この場合、クレーム名は DateOfBirth になり、クレームの値は、たとえば 8th June 1970 となります。そして発行者は、運転免許証機関になります。クレーム ベースの承認では、簡単に言うと、クレームの値がチェックされ、その値に基づいてリソースへのアクセスが許可されます。たとえば、ナイト クラブへの入場 (アクセス) であれば、承認プロセスは次のようになる可能性があります。"

"出入口のセキュリティ責任者が、あなたの生年月日クレームの値と、発行者 (運転免許機関) を信頼するかどうかを評価します。"

ちなみに、ClaimsIdentity クラスの解説は以下のようになっています。

"ClaimsIdentity クラスは、クレームベースの ID の具体的な実装です。つまり、クレームのコレクションによって記述される ID です。クレームは発行者によるエンティティに関する宣言で、プロパティ、権利、またはその他の品質が記述されます。このようなエンティティは、クレームの対象と言われます。クレームは Claim クラスによって表されます。ClaimsIdentity に含まれるクレームは、対応する ID が表すエンティティに記述し、承認と認証の決定を行うために使用できます。"

免許証の例を読んでからでないと分かりにくいかも。何を隠そう、自分はさっぱり分かりませんでした。(笑)

Tags: , , ,

Authentication

ASP.NET Core MVC でチャンク形式でダウンロード

by WebSurfer 4. August 2024 19:20

ASP.NET Core MVC のアクションメソッドを使って、ファイルをチャンク形式でエンコーディングしてブラウザにダウンロードする方法を書きます。(Core 版の MVC アプリの話です。.NET Framework 版は先の記事「MVC でチャンク形式でダウンロード」を見てください)

チャンク形式でダウンロード

チャンク形式エンコーディングとは、 HTTP/1.1 で定義されている方式で、送信したいデータを任意のサイズのチャンク(塊)に分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。(HTTP/2 はチャンク方式に対応しておらず、もっと効率的なデータストリーミングの仕組みを提供しているそうです)

メリットは、例えば、作成に時間がかかる大量のデータを動的に作成していて、作成中は全体のサイズが分からないが、部分的にでも作成でき次第送信を始められるというところにあるようです。(一旦全データをバッファして全体のサイズを調べ、Content-Length に設定するということをしなくても済みます)

概略方法を書きますと、(1) HttpResponse オブジェクトを取得、(2) それから Body プロパティを使って出力ストリームを取得、(3) コンテンツをチャンクに分割して WriteAsync メソッドでストリームに書き込む、(4) FlushAsync メソッドでクライアントに送信する、(5) 全チャンクを送信するまで (3) と (4) の操作を繰り返す・・・ということになります。

具体例はこの記事の下に載せたコードを見てください。test.pdf は 34,547 バイトの pdf ファイルで、下のコード例にあるアクションメソッドをブラウザから要求すると、その pdf ファイルのデータを 10,000 バイトずつチャンクに分けて送信するようになっています。

結果はこの記事の一番上の画像を見てください。Fiddler を使って要求・応答をキャプチャしたものです。応答ヘッダの赤線で示した部分を見るとチャンク形式エンコーディングになっていることが分かります。コンテンツの反転表示させた部分 32 37 31 30 に最初に送信されたチャンクのサイズが示されていることが分かります (文字コードは ASCII なので 32 37 31 30 は 2710 ⇒ 10 進数に直すと 10000)。

(注: Fiddler で応答コンテンツを見る際「Response body is encoded, Click to decode.」はクリックしないよう注意してください。クリックするとチャンクはまとめられ、さらに応答ヘッダには Content-Length が追加されて、チャンク形式ではなく普通にダウンロードされたように表示されます)

また、上のコードで送信データの最後を示す長さ 0 のチャンクも送信されています (Fiddler で最後のバイト列が 30 0D 0A 0D 0A となっているのを確認)。もちろん pdf ファイルも Content-Disposition に指定した名前で正しくダウンロードされます。

using Microsoft.AspNetCore.Mvc;

namespace MvcNet8App.Controllers
{
    public class DownloadController : Controller
    {
        // Core では Server.MapPath が使えないことの対応
        private readonly IWebHostEnvironment _hostingEnvironment;

        public DownloadController(IWebHostEnvironment hostingEnvironment)
        {
            _hostingEnvironment = hostingEnvironment;
        }

        // アクションメソッドの戻り値は void または Task にできる
        [HttpGet("/ChunkedDownload")]
        [ResponseCache(Duration = 0, 
                       Location = ResponseCacheLocation.None, 
                       NoStore = true)]
        public async Task ChunkedDownload(CancellationToken token)
        {
            // この例では、アプリケーションルート直下の Files という名前の
            // フォルダの中の test.pdf というファイルをダウンロードする
            string contentRootPath = _hostingEnvironment.ContentRootPath;
            string physicalPath = contentRootPath + "\\" + 
                                  "Files\\" + "test.pdf";

            // チャンクサイズは 10000 とした
            int chunkSize = 10000;
            Byte[] buffer = new Byte[chunkSize];

            using (var stream = new FileStream(physicalPath, FileMode.Open))
            {
                long length = stream.Length;

                // 応答ヘッダに Content-Type と Content-Disposition を含める
                Response.ContentType = "application/pdf";
                Response.Headers.Append("Content-Disposition", 
                                        "attachment;filename=test.pdf");

                // MVC5 の Response.IsClientConnected は使えないので代わりに
                // !token.IsCancellationRequested を使う
                // アクションメソッドの引数に CancellationToken を追加してお
                // けば、フレームワークが HttpContext.RequestAborted から取
                // 得した CancellationToken を引数にバインドしてくれる
                while (length > 0 && !token.IsCancellationRequested)
                {
                    // チャンク形式でダウンロードされていることを確認するため
                    // 入れたコード。コメントアウトを外すとここで 3 秒待つ
                    //await Task.Delay(3000, CancellationToken.None);

                    int lengthRead = await stream.ReadAsync(
                                            buffer.AsMemory(0, chunkSize),
                                            token);

                    // MVC5 の Response.OutputStream は使えない
                    // 同期版のWrite メソッドは AllowSynchronousIO がデフォル
                    // トで false なので使えない
                    await Response.Body.WriteAsync(
                                            buffer.AsMemory(0, lengthRead),
                                            token);

                    // MVC5 の Response.Flush() は使えない
                    await Response.Body.FlushAsync(token);

                    length -= lengthRead;

                }
            }
        }
    }
}

注意点があるので以下に書いておきます。

注 1: IIS を使ってのインプロセスホスティングモデルでホストされる ASP.NET Core Web アプリは、クライアントによる要求の中断を検出してサーバー側の処理をキャンセルすることができます (詳しくは先の記事「要求の中断による処理のキャンセル (CORE)」を見てください)。

しかしながら、上のコードの最初の FlushAsync で応答ヘッダと最初のチャンクがブラウザに送信された時点で、ブラウザの X ボタンは表示されなくなり Esc キーは効かなくなって、それらの操作で処理は中断できなくなります。Windows 10 の Chrome 127.0.6533.89, Edge 127.0.2651.86, Firefox 128.0.3, Opera 112.0.5197.39 ですべて同じになることを確認しました。

ブラウザを閉じた場合、Edge 以外では処理は中断されますが、Edge では処理が続行されてダウンロードが完了してしまいます。理由はクライアントによる要求の中断情報を IIS に送れなくて、サーバー側で CancellationToken がキャンセル状態にならないためと思われますが、詳細は調べ切れておらず不明です。

注 2: 上の注 1 のクライアントによる要求の中断は、プロキシが入ると CancellationToken が IIS に届かなくなり、上のコードでは検出できなくなるので注意してください。(何故で検出できないのか悩んでいたら Fiddler を使っていたというのは内緒です(笑))

注 3: チャンクのバイト列を応答ストリームに書き込むのに同期メソッドの Write は使えません。使うと InvalidOperationException がスローされ、"Synchronous operations are disallowed. Call WriteAsync or set AllowSynchronousIO to true instead." というエラーになります。理由は AllowSynchronousIO が Keatrel を使う場合でも IIS を使う場合でもデフォルトで false に設定されているからだそうです。AllowSynchronousIO を true に設定するのではなく、上のコードのように WriteAsync メソッドを使うのが正解と思います。

注 4: ReadAsync および WriteAsync メソッドで、引数に Byte[], Int32, Int32, CancellationToken を取るオーバーロードを使うと、"より効率的なメモリベースのオーバーロードを呼び出すことをお勧めします" とのことでパフォーマンスルール CA1835 が出るので、それに従って Memory<Byte> / ReadOnlyMemory<Byte>, CancellationToken を引数に取るオーバーロードを使いました。

Tags: , , ,

Upload Download

Fiddler が利用するサーバー証明書の更新

by WebSurfer 23. July 2024 15:51

Fiddler は、ブラウザなどの HTTP 通信を行うアプリケーションと Internet / Intranet の間の要求・応答をキャプチャして表示してくれる、Web アプリ開発には欠かせないツールです。(注: この記事は Windows OS 専用の Fiddler Classic の話です)

Fiddler Classic

HTTPS 通信もキャプチャ・復号して内容を表示してくれますが、そのために Fiddler は、Fiddler とクライアントの間に独自のサーバー証明書を利用します。

下の画像で赤枠で囲った DO_NOT_TRUST_FiddlerRoot がそれで、Fiddler のオプション設定によって「信頼されたルート証明機関」にインストールされます。

DO_NOT_TRUST_FiddlerRoot.jpg

証明書 NOT_TRUST_FiddlerRoot には有効期限があります。自動的には更新してくれないので、忘れた頃に突然証明書が無効だというエラーが出て焦ることになると思います。

下の画像がそれで、Fiddler を起動して、Visual Studio で起動した Web アプリを Chrome から呼び出して表示しようとしたら NOT_TRUST_FiddlerRoot の有効期限切れでエラーになったというものです。

ERR_CERT_DATE_INVALID

上の画像の NET::ERR_CERT_DATE_INVALID をクリックすると Subject:localhost 以下の詳細情報が表示されますので、その中の Issuer でどの証明書に問題があるのかが分かります。

(ちなみに、Fiddler と Web サーバーの間は別の証明書を使っており、それが開発用の証明書の場合は有効期限が切れることがままあって、その場合は Issuer が上の画像の DO_NOT_TRUST_FiddlerRoot とは異なるので分かります)

NOT_TRUST_FiddlerRoot の有効期限を延長するにはどうするかと言うと、Fiddler のツールバーの [Tools] ⇒ [Options] をクリックして Options ダイアログを開き、[HTTPS] タブの [Actions] ボタンをクリックしてプルダウンを表示し、[Reset All Certificates] をクリックしてやります。

Reset All Certificates

その先は表示される案内に従って進めていけば、「信頼されたルート証明機関」にインストールされた古い NOT_TRUST_FiddlerRoot 証明書が削除され、有効期限が延長された新しい証明書がインストールされます。

その際、[Decrypt HTTPS traffic] のチェックが外れることがあるようで、確認して外れていてらチェックを入れてください。

以上でエラーは解消できて、HTTPS 通信のトラフィックをキャプチャして表示できるようになるはずです。

Tags: , , ,

DevelopmentTools

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  April 2025  >>
MoTuWeThFrSaSu
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar