WebSurfer's Home

トップ > Blog 1   |   Login
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 Web API に Role ベースの承認を追加

by WebSurfer 20. July 2024 13:34

トークンベース (JWT) の認証を実装した ASP.NET Core Web API に Role による承認を追加する話を書きます。(認証と承認は違いますのでご注意ください)

クッキーベースの ASP.NET Identity 認証システムを実装した ASP.NET Web アプリでは、認証に加えて承認によるアクセスコントロールが可能です。承認には Role が使われます。例えばサイト管理者がマネージャー・ゲスト・管理者・メンバーなどといった Role を定義し、ユーザー別に Role をアサインすることにより細かいアクセスコントロールが可能になっています。

例えばアクションメソッドに [Authorize(Roles = "Admin")] という属性を付与したとすると、ユーザーが有効な ID とパスワードでログインして認証は通っていたとしても、Admin という Role を持っていないと承認されず、ユーザーはそのアクションメソッドにはアクセスできなくなります。

それは JWT ベースの認証機能を実装した Web API でも同様です。先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」で紹介した WeatherForecastController コントローラの Get() メソッドで、以下のように [Authorize] 属性を [Authorize(Roles = "Admin")] に変更してから Blazor アプリからアクセスすると、

// [Authorize]
[Authorize(Roles = "Admin")]
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
    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();
}

以下の画像のように HTTP 403 Forbidden 応答 (サーバーは要求を理解しましたが、要求の実行を拒否しています・・・と言う意味) が帰ってきてデータの取得に失敗します。ちなみに、認証が通らない場合は HTTP 401 Unauthorized 応答が返ってきます。

HTTP 403 応答

上の画像の #11 では、Blazor アプリからは有効な ID とパスワードが送信されるので認証は通ってトークンが返ってきています。そして、#13 でそのトークンを要求ヘッダに含めて上のアクションメソッド Get に要求をかけています。その結果、サーバー側では認証には成功するものの Admin ロールを持っていないので承認が通らず HTTP 403 応答が返ってきています。

Role による承認が通るようにするには、上の画像の #11 で返されるトークンに Admin ロールを持っているという情報を含める必要があります。それにはトークンを生成する際、Claims 情報として Admin ロールを追加してやります。

具体的には、先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」で紹介したトークンを発行する API の BuildToken メソッドで、以下のように Admin ロール情報を Claims に追加します。

private string BuildToken()
{
    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"],

        // Admin ロール情報を Claims に追加
        claims: [ new Claim(ClaimTypes.Role, "Admin") ],

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

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

これにより発行されるトークンには Admin ロールを持っているという情報が含まれます。以下の画像は発行されたトークンを JWT というサイトでデコードしたものです。赤枠部分を見てください。

Admin ロール情報を含む JWT

そのトークンを要求ヘッダに含めて要求をかければ承認が通って、下の画像の #6 の通りデータが返され、

200 応答

Blazor アプリには期待通り結果が表示されます。

Blazor アプリの結果の表示

Tags: , , , ,

Web API

React アプリから ASP.NET Core Web API を呼び出し

by WebSurfer 2. July 2024 15:36

React アプリから、トークン (JWT) ベースの認証が必要な ASP.NET Core Web API にクロスドメインでアクセスしてデータを取得するサンプルを作ってみました。

結果の表示

先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」の Blazor WASM アプリを React アプリに代えただけです。

Web API アプリの作り方は先の記事に詳しく書きましたのでそちらを見てください。

React アプリは、Visual Studio 2022 v17.10.3 のテンプレートを使って「新しいプロジェクトの作成」ダイアログで「React and ASP.NET Core」のテンプレートを選び、ターゲットフレームワーク .NET 8.0 で作成したものです。

その中の .client プロジェクトの src\App.jsx ファイルの populateWeatherData メソッドに以下のように手を加えて、先の記事の Web API を呼び出すようにしています。その結果が上の画像です。

import { useEffect, useState } from 'react';
import './App.css';

function App() {
    const [forecasts, setForecasts] = useState();

    useEffect(() => {
        populateWeatherData();
    }, []);

    const contents = forecasts === undefined
        ? <p><em>Loading... Please refresh once the ASP.NET backend has started.</em></p>
        : <table className="table table-striped" aria-labelledby="tabelLabel">
            <thead>
                <tr>
                    <th>Date</th>
                    <th>Temp. (C)</th>
                    <th>Temp. (F)</th>
                    <th>Summary</th>
                </tr>
            </thead>
            <tbody>
                {forecasts.map(forecast =>
                    <tr key={forecast.date}>
                        <td>{forecast.date}</td>
                        <td>{forecast.temperatureC}</td>
                        <td>{forecast.temperatureF}</td>
                        <td>{forecast.summary}</td>
                    </tr>
                )}
            </tbody>
        </table>;

    return (
        <div>
            <h1 id="tabelLabel">Weather forecast</h1>
            <p>This component demonstrates fetching data from the server.</p>
            {contents}
        </div>
    );
    
    async function populateWeatherData() {
        // 自動生成されたコード
        //const response = await fetch('weatherforecast');
        //const data = await response.json();
        //setForecasts(data);

        // ・・・を、以下のように書き換える

        const tokenUrl = "https://localhost:44366/api/token";
        const forecastUrl = "https://localhost:44366/WeatherForecast";

        // 送信する ID とパスワード
        const credentials = {
            Username: "oz@mail.example.com",
            Password: "myPassword"
        };

        const params = {
            method: "POST",
            body: JSON.stringify(credentials),
            headers: { 'Content-Type': 'application/json' }
        };

        // ID とパスワードを POST 送信してトークンを取得
        const responseToken = await fetch(tokenUrl, params);
        if (responseToken.ok) {
            const data = await responseToken.json();
            let token = data.token;

            // 取得したトークンを Authorization ヘッダに含めて
            // 天気予報データを GET 要求し、応答を表示
            const responseForecast = await fetch(forecastUrl,
                { headers: { 'Authorization': `Bearer ${token}` } });
            if (responseForecast.ok) {
                const data = await responseForecast.json();
                setForecasts(data);
            }
        }
    }
}

export default App;

不可解なのが、上のコードでは Web API が 2 回呼ばれることです。ネットの記事を見ると、上のコード例のように useState の第 2 引数に空の配列 [] を設定した場合は初回レンダリングの際の 1 回だけしか第 1 引数に設定した populateWeatherData() は実行されないそうですが、Fiddler で要求・応答をキャプチャしてみると 2 回呼び出しが行われていました。

ネットの記事をいろいろ探したところ、React の useEffect のドキュメント(これが公式?)の「外部システムへの接続」のセクションに以下の記述を見つけました。

"バグを見つけ出すために、開発中には React はセットアップとクリーンアップを、セットアップの前に 1 回余分に実行します。これは、エフェクトのロジックが正しく実装されていることを確認するストレステストです。"

この記事以外に 2 回呼ばれる理由と結びつくことが書かれた記事は見つけられませんでした。本番環境で検証するとかすれば分かるのかもしれませんが、今はその気力がありません。(汗) 調べる気力が出てきて、何か分かったら追記します。

ちなみに、React には「クラスコンポーネント」と「関数コンポーネント」というものがあるそうで、上のサンプルコードは「関数コンポーネント」を使っています。それゆえ React フックと呼ばれる useEffect を使わざるを得ないようです。

一方、古い Visual Studio のテンプレートで作った React アプリは「クラスコンポーネント」を使っており、componentDidMount で Web API の呼び出しを行っています。その場合は 2 回 Web API が呼ばれることは無いです。

Tags: , , ,

React

About this blog

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

Calendar

<<  September 2024  >>
MoTuWeThFrSaSu
2627282930311
2345678
9101112131415
16171819202122
23242526272829
30123456

View posts in large calendar