WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

VS2022 .NET 6.0 React のユーザー認証

by WebSurfer 2022年3月30日 11:37

Visual Studio 2022 の「React.js での ASP.NET Core」のテンプレートで[フレームワーク(F)]を「.NET 6.0 (長期的なサポート)」とし[認証の種類(A)]を「個別のアカウント」として作成したプロジェクトでユーザー認証に失敗する件につき自分が調べたことと解決策を備忘録として残しておきます。

2022/4/18 追記: 原因は .NET 6.0 Known Issues によるとプロキシのキャッシュとのことです。それに workaround が書いてありますが、この記事の一つ目の解決策と同じになります。詳しくはこの記事の下の方に追記します。

エラーメッセージ

有効な id と password でログインして ASP.NET Core Identity による認証が通っても、Fetch Data をクリックして Web API のデータを取得しようとすると、上の画像のように、

Unhandled Rejection (SyntaxError): Unexpected end of JSON input

・・・というエラーメッセージが出て失敗します。

理由ははっきりしませんが、エラーメッセージやいろいろ試してみた結果から想像するに、Web API のユーザー認証用の JWT トークンに含まれる Issuer と OpenId Connect を呼び出す際に使用する Authority に不整合が出るということのように思われます。

Visual Studio 2022 でフレームワークが .NET 6.0 の React プロジェクトを実行する場合、バックエンドの ASP.NET アプリ (Web API と IdentityServer を含む) に対する要求はプロキシを通るのでその影響があるのかもしれません。(フレームワークを .NET Core 3.1 とした場合は上のような問題は出ませんが、違いはプロキシが使われてないぐらいしかありませんので)

どういう状況かを詳しく書くと以下の通りです。

初期画面ではメニューバーに Home, Counter, Fetch data という 3 つのメニューが表示されており、その中の Fetch data はサーバーの ASP.NET Core Web API からデータを取得して表示するようになっています。

プロジェクトを作成する際[認証の種類(A)]に「個別のアカウント」を選んだ場合、サーバーの Web API には JWT トークンベースの認証が実装され、JWT トークンなしで Web API にアクセスすると HTTP 401 Unauthorized 応答が返ってくるように設定されます。

Visual Studio でアプリを実行し、認証を受けてない状態でメニューバーの Fetch data をクリックするとログイン画面にリダイレクトされます。ログイン画面で id と password を入力して認証を受けると JWT トークンが発行され、Fetch data が Web API にアクセスしてデータを取りに行きますが、その際 JWT トークンが要求ヘッダに含まれるようになります。(先にメニュバーの Login をクリックしてログイン画面でログインしてから Fetch data をクリックしても同様です)

Web API には JWT トークンを持ってアクセスに行くので認証が通って Web API からのデータの取得に成功し下の画像のようにデータが表示されるということになっています。

Fetch data 画面

しかし、.NET 6.0 の場合はこの記事の一番上の画像のエラーとなります。(ちなみに、.NET Core 3.1 の場合は認証が通って上の画面の通りデータが表示されます)

エラーメッセージによると FetchData.js の 60 行目でエラーとなったとのこと。デバッグしてみると HTTP 401 Unauthorized という応答が返ってきています。

デバッグ画面

Fiddler で調べてみると、下の画像の青枠で示した通り JWT トークンは送信されていますが、赤枠で囲った部分に、

www-authenticate: Bearer error="invalid_token", error_description="The issuer 'https://localhost:44495' is invalid"

・・・と表示されているように Issuer が無効とのことです。

Fiddler の画面

JWT トークンに含まれる Issuer 'https://localhost:44495' と何かに不整合が出ているのは間違いないようですが、.NET Core 3.1 では問題ないのに .NET 6.0 で問題が出る理由が分かりませんでした。

調べてみると、Visual Studio 2022 で作成する .NET 6.0 の React アプリは、Viusal Studio から実行すると React 部分は Node.js 開発サーバーでホストされ (ホットリロードが可能になっています)、サーバー側の ASP.NET Core アプリ (Web API と IdentityServer を含む) は IIS Express または Kestrel でホストされるようになっています。

Node.js 開発サーバーと IIS Express (または Kestrel) では必然的にホスト名が異なりクロスドメインの問題が出ます。その問題を解決するために http-proxy-middleware (HPM) という Node.js のプロキシを使っていて、Visual Studio から .NET 6.0 の React アプリを起動するとプロジェクトに含まれている setupProxy.js の設定に従い以下の通り HPM を起動する画面が出ます。

http-proxy-middleware (HPM)

上の画像の通り Web API のコントローラーの URL 'weatherforecast' や IdentityServer の APIがプロキシの対象に含まれており、それへの要求を受けると https://localhost:44395 のサーバー (IIS Express) へ渡すように設定されています。

(https://localhost:44395 は launchSettings.json に設定されている IIS Express を使った場合の URL です。Kestrel を使う場合は別のポートになりますが、Visual Studio でアプリを起動する際の設定に従い自動的に切り替わります)

Node.js 開発サーバーの URL はプロジェクトファイルに含まれている SpaProxyServerUrl の設定になるようです。下の画像の通り https://localhost:44495 になっています。

プロジェクトファイル

まとめると、開発環境での URL 'https://localhost:xxxxx' のポート xxxxx は以下のようになります。

ブラウザ (44495) ⇔ Node.js 開発サーバー (44495) ⇔ NPM ⇔ IIS Express (44395)

JWT トークンを発行するのは IIS Express でホストされる IdentityServer ですが、その際 "iss" : "https://localhost:44495" と設定されます。それは JSON Web Tokens - jwt.io で JWT トークンを解析して下の画像のようになっていることで確認しました。(44395 でなく 44495 となっている理由は不明)

JWT トークンを解析

さらに調べていると Microsoft のドキュメント「SPA の認証と承認」の Azure App Service on Linux というセクションに "Linux での Azure App Service デプロイについては、Startup.ConfigureServices に発行者を明示的に指定します" と書いてあって、その対応のためのコードが載っているのを見つけました。

自分の環境には Linux も Azure も関係ないはずですが、試しにそのコードを Program.cs に追加したら問題は解決しました。コメントで「// 追加」とした部分が追加したコードで、それ以外はテンプレートが自動生成した既存のコードです。JwtBearerOptions クラスの Authority プロパティを "https://localhost:44495" に設定しています。

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI;
using Microsoft.EntityFrameworkCore;
using ReactNet6Identity.Data;
using ReactNet6Identity.Models;

// 追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.ApiAuthorization.IdentityServer;

var builder = WebApplication.CreateBuilder(args);

// 追加
builder.Services.Configure<JwtBearerOptions>(
    IdentityServerJwtConstants.IdentityServerJwtBearerScheme,
    options =>
    {
        options.Authority = "https://localhost:44495";
    });

// ・・・以下略・・・

Microsoft のドキュメント「JwtBearerOptions.Authority プロパティ」の説明によると、"OpenIdConnect を呼び出す際に使用する Authority を取得または設定します" ということで、上のコードはその Authority を "https://localhost:44495" に設定したということになるはずです。

でも無知な自分には OpenIdConnect って何? Authority って何? ・・・という感じで、なぜ問題が出なくなったのかさっぱり分かりません。(汗)

もう一つ見つけた解決策は、これもなぜ問題が出なくなるのが分かりませんが、IdentityServerOptions クラスの IssuerUri プロパティに "https://localhost:44495" を設定することです。Program.cs の既存のコードに手を加えて以下のようにします。

builder.Services.AddIdentityServer(options => 
    options.IssuerUri = "https://localhost:44495")
    .AddApiAuthorization<ApplicationUser, ApplicationDbContext>();

このような設定をしなくても、そもそもが Issuer は "https://localhost:44495" になるのに、なぜこれが解決になるのか分かりません。想像ですが、明示的に設定すると JwtBearOptions の Authority に影響があるのかもしれません。

React アプリの認証について少し調べてみましたが、Microsoft のドキュメント「SPA の認証と承認」によると、React アプリには ASP.NET Core Identity をユーザー情報のストアとして使う OpenID Connect ベースの認証サーバー Duende Identity Server を使えるように Visual Studio のテンプレートが作られているようです。

では OpenID Connect とは何かですが、「Microsoft ID プラットフォームと OpenID Connect プロトコル」によると、"OpenID Connect (OIDC) は OAuth 2.0 を基盤とした認証プロトコルであり、ユーザーをアプリケーションに安全にサインインさせるために利用できます" とのことです。

ASP.NET Core MVC などに使う ASP.NET Core Identity によるクッキーベースの認証や、先の記事「ASP.NET Core Web API と JWT」で書いたようなトークンベースの単純な認証方式とは違うようだということは何となく分かりました。勉強しなくては・・・


2022/4/18 追記

"The issuer 'https://localhost:44495' is invalid" となる原因が分かりました。.NET 6.0 Known IssuesSPA template issues with Individual authentication when running in development というセクションがあって、それに以下のように書いてありました。

"The first time SPA apps are run, the authority for the spa proxy might be incorrectly cached which results in the JWT bearer being rejected due to Invalid issuer. The workaround is to just restart the SPA app and the issue will be resolved. If restarting doesn't resolve the problem, another workaround is to specify the authority for your app in Program.cs: builder.Services.Configure<JwtBearerOptions>("IdentityServerJwtBearer", o => o.Authority = "https://localhost:44416"); where 44416 is the port for the spa proxy."

要するに、プロキシが Authority (JWT トークンの発行者 = IdentityServer) を間違ってキャッシュするため、送信されてきた JWT トークンに含まれる発行者と一致しなくて、invalid と判定されたということのようです。

.NET 6.0 Known Issues に書いてある workaround は Authority を JWT トークンに含まれる "iss" : "https://localhost:xxxxx" に合わせるということで、この記事に 2 つ書いてある解決策の一つ目と同じです。この記事の例では JWT トークンは "iss" : "https://localhost:44495" となっており、それを Authority に設定しています。

この記事の二つ目の解決策は IdentityServer 側で発行者を "https://localhost:44495" に設定するというものです。これでプロキシのキャッシュの Authority が "https://localhost:44495" になって、JWT トークンの "iss" : "https://localhost:44495" と一致するのではないかと想像してます。

ただ、まだ分からない点があります。それはテンプレートでプロジェクトを作成した状態のデフォルトで、JWT トークンに含まれる "iss" が "https://localhost:44495" になることです。この記事の例では IdentityServer は "https://localhost:44395" なのでそれが Authority になり、IdentityServer が発行した JWT トークンは "iss" : "https://localhost:44395" になるはずなのですが・・・

もう一つ、.NET 6.0 Known Issues に書いてある以下の件ですが、こちらはプロジェクト作成直後の DB が生成されてない状態で登録・ログインしようとすると表示されるはずのメッセージが、プロキシの問題で表示されないというものです。JWT トークンが invalid と判定されるというこの記事の話とは別の問題です。

"When using localdb (default when creating projects in VS), the normal database apply migrations error page will not be displayed correctly due to the spa proxy. This will result in errors when going to the fetch data page. Apply the migrations via 'dotnet ef database update' to create the database."

Tags: , ,

React

ASP.NET Web API と JWT (CORE)

by WebSurfer 2020年2月11日 18:48

ASP.NET Core 3.1 Web API でトークンベース認証を実装してアクセス制限し、ユーザー認証に ASP.NET Core Identity のユーザー情報を利用する方法を書きます。

結果の表示

.NET Framework Web API の場合は、先の記事「ASP.NET Web API の認証」で書きましたように、Visua Studio のテンプレートを使って認証を「個別のユーザーアカウントカウント」として自動生成すればデフォルトでトークンベースの認証が実装され、認証のためのユーザー情報のストアには ASP.NET Identity が使用されます。

Core Web API では自力での実装が必要になります。テンプレートで認証に「個別のユーザーアカウントカウント」を選択すると「クラウドの既存のユーザーストアに接続する」しか選べません。なので、認証なしの状態から Core 2 からサポートされたという JSON Web Token (JWT) を使った認証を実装することにします。

基本的な方法は Auth0 というサイトのブログの記事「ASP.NET Core 2.0 アプリケーションを JWT でセキュアする」(以下「Auth0 の記事」と書きます)に書いてあるのでそれを見れば済む話なのですが、リンク切れになったりすると困るので要点およびその記事には書いてないことを備忘録として残しておきます。

(1) プロジェクトの作成

元になる ASP.NET Core 3.1 Web API アプリのプロジェクトは Visual Studio Community 2019 のテンプレートで自動生成されたものを使います。以下の画像を見てください。認証は「なし」にしておきます。

プロジェクトの作成

テンプレートで自動生成したプロジェクトにはサンプルのコントローラ WeatherForecastController が実装されていて、Visual Studio からプロジェクトを実行([デバッグ(D)]⇒[デバッグなしで開始(H)])すると JSON 文字列が返ってきます。

そのアクションメソッド Get() に JWT ベースの認証を実装します(即ち、トークンが無いとアクセス拒否するようにします)。

(2) NuGet パッケージのインストール

下の画像の赤枠で囲んだ Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。青枠で囲んだものは、下に述べたユーザー認証に ASP.NET Core Identity のユーザー情報を利用する場合に必要になります。

NuGet パッケージのインストール

(3) JWT 認証スキーマを登録

自動生成された Startup.cs のコードの ConfigureServices メソッドで、AddAuthentication メソッドを使って JWT 認証スキーマを登録します。コードは Auth0 の記事のものをそのままコピペすれば OK です。using 句の追加を忘れないようにしてください。

さらに、認証を有効にするため Configure メソッドに app.UseAuthentication(); を追加します。既存のコードの app.UseAuthorization(); の前にする必要があるので注意してください。

具体的には以下のコードで「JWT ベースの認証を行うため追加」とコメントしたコードを追加します。

// ・・・前略・・・

// JWT ベースの認証を行うため追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

//・・・中略・・・

  public void ConfigureServices(IServiceCollection services)
  {
    // JWT ベースの認証を行うため追加
    services.AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
          options.TokenValidationParameters = 
            new TokenValidationParameters
            {
              ValidateIssuer = true,
              ValidateAudience = true,
              ValidateLifetime = true,
              ValidateIssuerSigningKey = true,
              ValidIssuer = Configuration["Jwt:Issuer"],
              ValidAudience = Configuration["Jwt:Issuer"],
              IssuerSigningKey = new SymmetricSecurityKey(
                  Encoding.UTF8.GetBytes(
                      Configuration["Jwt:Key"]))
            };
        });

    services.AddControllers();
  }

  public void Configure(IApplicationBuilder app, 
            IWebHostEnvironment env)
  {
    // ・・・中略・・・

    // JWT ベースの認証を行うため追加
    app.UseAuthentication();

    app.UseAuthorization();

    //・・・後略・・・

(4) Key と Issuer を appsettings.json に登録

上の (3) コードでは Key と Issuer を appsettings.json ファイルより取得するようにしていますので、以下のように "Jwt" 要素を追加します。

{

  ・・・中略・・・

  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKey",
    "Issuer": "https://localhost:44330"
  }
}

既存の "AllowedHosts": "*" の後にカンマ , を追加するのを忘れないようにしてください。Key はパスワードのようなもので任意の文字列を設定できます(16 文字以上にしないとエラーになるようです)。Issuer はサービスを行う URL にします。

(5) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。using Microsoft.AspNetCore.Authorization; の追加を忘れないようにしてください。

ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずです。

(6) トークンを取得する API を実装

ユーザーの ID とパスワードを送信してトークンを取得する API を実装します。基本的には Auth0 の記事のコントローラ TokenController の通りですが、それを拡張してユーザー情報を既存の ASP.NET Core Identity のデータベースから取得して認証を行うようにしてみました。

コントローラ TokenController のコードは以下の通りです。UserManager<IdentityUser> オブジェクトへの参照を DI によって取得し、それを使って既存の ASP.NET Core Identity から情報を取得してユーザー認証に用いています。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
// 以下を追加
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.AspNetCore.Identity;

namespace WebApiJwtIdentity.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();
      var user = await _userManager.FindByNameAsync(id);
      if (user != null && 
          await _userManager.CheckPasswordAsync(user, pw))
      {
        var tokenString = BuildToken();
        response = Ok(new { token = tokenString });
      }

      return response;
    }

    private string BuildToken()
    {
      var key = new SymmetricSecurityKey(
          Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
      var creds = new SigningCredentials(
          key, SecurityAlgorithms.HmacSha256);

      var token = new JwtSecurityToken(
        _config["Jwt:Issuer"],
        _config["Jwt:Issuer"],
        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; }
  }
}

上記のコード以外にも以下の追加が必要です。

(a) 上の (2) の画像で青枠で囲んだ NuGet パッケージのインストール。

(b) IdentityDbContext を継承した ApplicationDbContext クラスを追加。Data フォルダを作ってそれにクラスファイルとして実装します。

using System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace WebApiJwtIdentity.Data
{
    public class ApplicationDbContext : IdentityDbContext
    {
        public ApplicationDbContext(
            DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

(c) appsettings.json に ASP.NET Core Identity が使う既存の SQL Server DB への接続文字列を追加。

(d) Startup.cs に以下を追加。

// 追加
using Microsoft.AspNetCore.Identity;
using WebApiJwtIdentity.Data;
using Microsoft.EntityFrameworkCore;

public void ConfigureServices(IServiceCollection services)
{
  // 追加
  services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
      Configuration.GetConnectionString(
        "MvcCoreIdentityContextConnection")));

  // 追加
  services.AddDefaultIdentity<IdentityUser>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

(7) 検証用 Home/Index ページを追加

以下は必須ではないですが、検証用の Home/Index ページを追加し、そこから jQuery ajax を使ってトークンの取得と認証が期待通りとなるかを確認してみます。

View のコードは以下のようになります。下のコードの Username と Password には "***" ではなくて有効な文字列を設定してください。このページを使って確認した結果が一番上の画像です。

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
  <meta name="viewport" content="width=device-width" />
  <title>Index</title>
  <script src="~/Scripts/jquery.js"></script>
  <script type="text/javascript">
  //<![CDATA[
    var tokenKey = 'accessToken';

    function getToken() {            
      var obj = { Username : "***", Password : "***" };
      var jsonString = JSON.stringify(obj);
      $.ajax({
        type: "POST",
        url: "/api/token",
        data: jsonString,
        contentType: "application/json; charset=utf-8",
        success: function (data, textStatus, jqXHR) {
          sessionStorage.setItem(tokenKey, data.token);
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

    function weatherForecast() {
      var token = sessionStorage.getItem(tokenKey);
      var headers = {};
      if (token) {
        headers.Authorization = 'Bearer ' + token;
      }

      $.ajax({
        type: "GET",
        url: "/WeatherForecast",
        headers: headers,
        cache: false,
        success: function (data, textStatus, jqXHR) {
          $('#output').empty();
          $.each(data, function (key, val) {
            var day = new Date(val.date);
            var dateString = day.getFullYear() + "年" +
                (day.getMonth() + 1) + "月" +
                day.getDate() + "日";
            $('#output').append(
              '<p>' + dateString + ' / ' +
              val.temperatureC + ' / ' +
              val.temperatureF + ' / ' +
              val.summary + '</p >');
          });                    
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

  //]]>
  </script>
</head >
<body>
    <h3>Web API から jQuery ajax を使ってデータの取得</h3>

    <input type="button" id="Button1" 
      value="WeatherForecast" onclick="weatherForecast();" />
    <input type="button" id="Button2" 
      value="Get Token" onclick="getToken();" />

    <hr />
    <p>結果の表示:</p>
    <div id="output"></div>
</body>
</html >

なお、元々のプロジェクトの設定が Web API 用ですので、そのままでは MVC 用の Controller と View は動きませんので注意してください。以下の設定が必要になります。

(a) Startup.cs で MVC 用のサービスの追加、静的ファイルの利用を可能にすること、ルーティングのためのマップ設定。

(b) launchSettings.json で "launchUrl" の "weatherforecast" を "home/index" に変更。

(c) jQuery を利用するので wwwroot/Script/jQuery.js を追加。

Tags: , , ,

Web API

About this blog

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

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar