WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Blazor WASM から ASP.NET Core Web API を呼び出し

by WebSurfer 29. June 2024 11:42

ASP.NET Core Blazor Web Assembly (WASM) からトークン (JWT) ベースの認証が必要な ASP.NET Core Web API にクロスドメインでアクセスしてデータを取得するサンプルを作ってみました。以下に作り方を備忘録として書いておきます。

結果の表示

Visual Studio 2022 のテンプレートを利用して ASP.NET Core Web API と Blazor WASM のソリューションを別々に作成します。完成後、Visual Studio 2022 から両方のプロジェクトを実行し ([デバッグ(D)]⇒[デバッグなしで開始(H)])、Blazor WASM から Web API に要求を出して応答を Blazor WASM の画面上に表示したのが上の画像です。

以下に、まず Web API アプリの作り方、次に Blazor WASM アプリの作り方を書きます。

(1) Web API アプリ

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

元になる ASP.NET Core Web API アプリのプロジェクトは Visual Studio 2022 V17.10.3 のテンプレートで自動生成されたものを使いました。プロジェクトを作成する際「追加情報」のダイアログで「認証の種類(A)」は「なし」にします。この記事ではターゲットフレームワークは .NET 8.0 にしました。

自動生成されたプロジェクトにはサンプルのコントローラ WeatherForecastController が実装されていて、Visual Studio からプロジェクトを実行し、ブラウザから WeatherForecast を要求すると JSON 文字列が返ってきます。

これに JWT ベースの認証を実装し(即ち、トークンが無いとアクセス拒否するようにし)、さらに Blazor WASM からクロスドメインで呼び出せるようにするため CORS を実装します。

加えて、クライアントからの要求に応じてトークンを発行するための API も追加で実装します。この記事では、トークン要求の際クライアントから ID とパスワードを送信してもらい、それらが既存の ASP.NET Core Identity で有効であることを確認してからトークンを返すようにします。無効の場合は HTTP 401 Unauthorized 応答を返します。

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

下の画像の赤枠で囲んだ Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。

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

青枠で囲んだものは、上に述べたトークン発行の際の既存の ASP.NET Core Identity によるユーザー認証を行うために必要です。ASP.NET Core Identity を使わない場合(例えば、ユーザー認証なしで無条件にトークンを返すようにする場合)は必要ありません。

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

自動生成された Program.cs に、AddAuthentication メソッドを使って JWT 認証スキーマを登録するコードを追加します。加えて、認証を有効にするため app.UseAuthentication(); も追加します。

app.UseAuthentication(); は既存のコードの app.UseAuthorization(); の前にする必要があるので注意してください。

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

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using WebApi.Data;
using Microsoft.EntityFrameworkCore;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

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

            // ・・・中略・・・

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

            //・・・後略・・・

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

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

{

  ・・・中略・・・

  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKeyWhichMustBeLongerThan32",
    "Issuer": "https://localhost:44366"
  }
}

Key はパスワードのようなもので任意の文字列を設定できます。32 文字以上にしないとエラーになるので注意してください。.NET Core 3.1 時代は 16 文字以上で良かったのですが、いつからか 32 文字以上に変わったらしいです。Issuer はサービスを行う URL にします。

(1.5) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。

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

(1.6) トークンを発行する API を実装

クライアントから送信されてきた ID とパスワードでユーザー認証を行った上でトークンを発行する API を実装します。以下のコードでは、UserManager<IdentityUser> オブジェクトへの参照を DI によって取得し、それを使って既存の ASP.NET Core Identity から情報を取得してユーザー認証に用いています。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
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))
                {
                    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(
                issuer: _config["Jwt:Issuer"],
                audience: _config["Jwt:Issuer"],
                claims: null,
                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; }
    }
}

既存の ASP.NET Core Identity から情報を取得してユーザー認証を行うためには上記以外にも以下の (a) ~ (d) の追加が必要です。

ただし、ユーザー認証など面倒なことはしないで、CreateToken メソッドが呼ばれたら無条件にトークンを発行して返せばよいという場合は不要です。上のコードの UserManager<IdentityUser> オブジェクトの DI を行う部分も不要です。

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

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

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

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

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

(d) Program.cs に以下の「// 追加」とコメントしたコードを追加。これらは上の「(1.3) JWT 認証スキーマを登録」に書いたコード builder.Services.AddAuthentication(...); より前に追加する必要があるので注意してください。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using WebApi.Data;
using Microsoft.EntityFrameworkCore;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // 追加
            builder.Services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    builder.Configuration.GetConnectionString(
                        "MvcCoreIdentityContextConnection")));

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

            //・・・後略・・・

(1.7) CORS 機能の実装

Blazor WASM からクロスドメインで Web API を呼び出すため、Web API アプリに CORS 機能を実装します。

具体的には、Program.cs に以下のコードを追加します。プリフライトリクエストが行われますので AllowAnyHeader() と AllowAnyMethod() が必要です。AllowCredentials() はトークンベースの認証の場合は不要のようです。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using WebApi.Data;
using Microsoft.EntityFrameworkCore;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // 追加
            var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
            builder.Services.AddCors(options =>
            {
                options.AddPolicy(name: MyAllowSpecificOrigins,
                                  policy =>
                                  {
                                      policy.AllowAnyOrigin()
                                            .AllowAnyHeader()
                                            .AllowAnyMethod();
                                  });
            });

            // ・・・中略・・・

            // 追加 
            app.UseCors(MyAllowSpecificOrigins);

            //・・・後略・・・

(2) Blazor WASM アプリ

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

Visual Studio 2022 の新しいプロジェクトの作成で「Blazor WabAssembly アプリ」のテンプレート (Blazor Web App を選ばないよう注意)を使って自動生成されたものを使います。「追加情報」のダイアログで「認証の種類(A)」は「なし」にします。この記事ではターゲットフレームワークは .NET 8.0 にしました。

(2.2) Weather.razor の修正

自動生成されたプロジェクトの Weather.razor には、wwwroot 下の json ファイル weather.json を要求して、応答の JSON 文字列をデシリアライズして表示するコードが含まれています。

その部分のコードを、Web API からトークンを取得した後、トークンを要求ヘッダに含めて送信し、応答として返されたデータを表示するように変更します。

変更するのは @code ブロックのみで、以下の通りとなります。

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // 以下はテンプレートで自動生成されたコードに含まれているもの
        // これを下のように書き換える
        // forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");

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

        // 送信する ID とパスワード。既存の ASP.NET Core Identity で有効なもの
        var credentials = new {
            Username = "oz@mail.example.com",
            Password = "myPassword"
        };

        // ID とパスワードを送信してトークンを取得
        // content-type: application/json; charset=utf-8 は自動的に
        // ヘッダに付与される
        using var tokenResponse = await Http.PostAsJsonAsync(tokenUrl, credentials);
        var jwt = await tokenResponse.Content.ReadFromJsonAsync<JWT>();

        if (jwt != null && !string.IsNullOrEmpty(jwt.Token))
        {
            // 取得したトークンを Authorization ヘッダに含めて GET 要求
            var request = new HttpRequestMessage(HttpMethod.Get, forecastUrl);
            request.Headers.Authorization = 
                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt.Token);
            using var forecastResponse = await Http.SendAsync(request);
            forecasts = await forecastResponse.Content.ReadFromJsonAsync<WeatherForecast[]>();
        }
    }

    // 追加
    public class JWT
    {
        public string? Token { get; set; }
    }

    public class WeatherForecast
    {
        public DateOnly Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

デスクトップアプリで使う HttpClient の場合とほぼ同じコードになりました。。Blazor WASM のクライアント側はブラウザなので、HttpClient は動くはずはなく、XMLHttpRequest とか fetch などを使った JavaScript のコードに変換されてブラウザに送られたコードが動いているのではないかと思います。

ブラウザからのクロスドメインでの要求で、かつシンプルなリクエストとはならないので、CORS 対応のためのプリフライトリクエストが必要なヘッダ情報を含めて送信されます。Fiddler で要求・応答をキャプチャするとそのあたりのことが分かります。

Fiddler で要求・応答をキャプチャ

Tags: , , , ,

CORE

認証チケットの期限切れをユーザーに通知

by WebSurfer 11. May 2024 16:15

ASP.NET Core Identity を使ったユーザー認証システムで認証チケットの有効期限が切れたとき、下の画像のようにユーザーにその旨通知する方法を書きます。

認証チケット有効期限切れの通知

先の記事「ASP.NET Core Identity タイムアウト判定」で書いたこととほぼ同じで、違うのは先の記事ではミドルウェアを使っていたのを Login ページで行うようにし、ターゲットフレームワークを .NET Core 3.1 から .NET 8.0 に変更した点です。

Visual Studio 202 のテンプレートを使って ASP.NET Core Web アプリのプロジェクトを作る際、認証を「個別のユーザーアカウント」に設定すると ASP.NET Core Identity を利用したクッキーベースのユーザー認証が実装されます。

ユーザーがログインに成功すると、サーバーからは応答ヘッダの Set-Cookie に認証チケットを入れて認証クッキーとしてクライアント(ブラウザ)に送信します。

その後は、ブラウザは要求の都度サーバーに認証クッキーを送信するので認証状態が継続されるという仕組みになっています。

認証チケットには有効期限があります (デフォルトで 5 分、SlidingExpiration で延長あり)。一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたまま長時間席を外すなどして、タイムアウトに設定した時間を超えてアクセスしなかった場合を考えてください。

タイムアウトとなった後でユーザーが席に戻ってきて再度どこかのページを要求したとすると、要求したページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。

その際、ユーザーに認証チケットが期限切れとなっていることを知らせるためには、どのようにできるかということを書きます。

一旦認証を受けたが、認証チケットが期限切れになっているというのは、Web サーバー側では以下の条件で判定できるはずです。

  1. 要求 HTTP ヘッダーに認証クッキーが含まれる。
  2. 認証クッキーの中の認証チケットが期限切れ。

一旦認証クッキーの発行を受ければ、ブラウザを閉じない限り要求の都度サーバーにクッキーを送り続けます。(下の「注 1」に書きましたが、[Remember me?]チェックボックスにチェックを入れて認証を受けた場合は話が違ってくるので注意してください)

Web サーバーが認証クッキーを取得できれば、それを復号して認証チケットを取得し、チケットの中の有効期限の日時の情報を取得できます。それで上記の 2 つの条件を確認できます。

ログインページで条件を確認して、認証チケットが期限切れになっていた場合はこの記事の一番上の画像のようにメッセージを表示してみました。

以下がログインページのコードです。スキャフォールディング機能を利用して自動生成した Login.cshtml.cs, Login.cshtml のコードに手を加えています。(スキャフォールディング方法の詳細は別の記事「ASP.NET Core MVC プロジェクトに Identity 実装」を見てください)

Login.cshtml.cs

コメントで「2024/5/11 追加」としたコードを自動生成された Login.cshtml.cs に追加しています。

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authentication;
// ・・・中略・・・

namespace MvcNet8App2.Areas.Identity.Pages.Account
{
    public class LoginModel : PageModel
    {
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly ILogger<LoginModel> _logger;

        // 2024/5/11 追加(下の「注 2」参照)
        private readonly CookieAuthenticationOptions _options;

        public LoginModel(SignInManager<ApplicationUser> signInManager, 
                          ILogger<LoginModel> logger,

                          // 2024/5/11 追加(下の「注 2」参照)
                          IOptions<CookieAuthenticationOptions> options)
        {
            _signInManager = signInManager;
            _logger = logger;

            // 2024/5/11 追加(下の「注 2」参照)
            _options = options.Value;
        }

        // ・・・中略・・・

        public async Task OnGetAsync(string returnUrl = null)
        {
            // 2024/5/11 追加・・・
            // .AspNetCore.Identity.Application はデフォルトのクッキー名
            string cookie = Request.Cookies[".AspNetCore.Identity.Application"];
            if (!string.IsNullOrEmpty(cookie))
            {
                IDataProtectionProvider provider = 
                                      _options.DataProtectionProvider;

                // 下の「注 3」参照
                IDataProtector protector = provider.CreateProtector(
                    // CookieAuthenticationMiddleware のフルネーム
                    "Microsoft.AspNetCore.Authentication.Cookies." +
                    "CookieAuthenticationMiddleware",
                    // クッキー名 .AspNetCore.Identity.Application から
                    // .AspNetCore. を除去した文字列
                    "Identity.Application",
                    // .NET Framework 版は "v1"、Core 版は "v2"
                    "v2");

                // 認証クッキーから暗号化された認証チケットを復号
                TicketDataFormat format = new TicketDataFormat(protector);

                // 下の「注 3, 4」参照
                AuthenticationTicket authTicket = format.Unprotect(cookie);

                // ユーザー名を取得
                ClaimsPrincipal principal = authTicket.Principal;
                IIdentity identity = principal.Identity;
                string userName = identity.Name!;

                // 認証チケットの有効期限の日時を取得
                AuthenticationProperties property = authTicket.Properties;
                DateTimeOffset? expiersUtc = property.ExpiresUtc;

                // expiresUtc と現在の時刻を比較して期限切れか否かを判定
                if (expiersUtc.Value < DateTimeOffset.UtcNow)

                {
                    ViewData["AuthTicket"] = $"{userName} さん、"+
                      $"認証チケットの有効期限 {expiersUtc.Value} が切れています。";
                }
            }
            // ・・・追加はここまで

            // ・・・中略・・・
        }

        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            
            // ・・・中略・・・

        }
    }
}

Login.cshtml

認証ケットが期限切れの場合は cshtml から ViewData で情報をもらって期限切れのメッセージを表示するための一行を追加したのみです。

@page
@model LoginModel

@{
    ViewData["Title"] = "Log in";
}

<h1>@ViewData["Title"]</h1>

@* 2024/5/11 以下の一行を追加 *@
<p><font color=red>@ViewData["AuthTicket"]</font></p>

<div class="row">
    <div class="col-md-4">
        <section>

・・・中略・・・

</div>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

注 1 : デフォルトのログインページには[Remember me?]チェックボックスがあって、これにチェックを入れて認証を受けると Set-Cookie ヘッダに expires 属性が追加され認証クッキーが HDD / SSD に保存されます。expires 属性に指定される期限は認証チケットの有効期限と同じになります。期限を過ぎるとブラウザ側で認証クッキーを削除してしまいますので、条件 1 の「要求 HTTP ヘッダーに認証クッキーが含まれる」ことの判定はできなくなります。

注 2 : DI 機能により CookieAuthenticationOptions オブジェクトを取得します。それをベースに復号に必要な TicketDataFormat オブジェクトを取得し、認証クッキーから認証チケットを復号しています。

Visual Studio で「個別のユーザーアカウント」を認証に選んで作成したプロジェクトまたはスキャフォールディング機能を使って ASP.NET Core Identity を追加したプロジェクトでは以下のコードが Program.cs 含まれるはずです。その場合、コンストラクタの引数に IOptions<CookieAuthenticationOptions> options を含めるだけで DI 機能が働きます。

builder.Services.AddDefaultIdentity<ApplicationUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>()

注 3 : IDataProtectionProvider.CreateProtector メソッドの引数ですが、これには暗号化するときに使用した値 (暗号化を特定の目的にロックするためのものらしい)と同じものを設定する必要があります。それが CookieAuthenticationMiddleware の名前、認証クッキー名、ASP.NET Identity のバージョンになるようです。

暗号化するときに使用した値と、上の CreateProtector メソッドの引数に指定したものが違うと復号に失敗し、上のコードで authTicket が null になり、次の行の authTicket.Principal で NullReferenceException がスローされます。

注 4 : 別の記事「ASP.NET Core でのデータ保護キーの管理」で書きましたがデータ保護キーがリサイクルで失われることがあります。その場合は復号に失敗し、上のコードで authTicket が null になり、次の行の authTicket.Principal で NullReferenceException がスローされます。

リサイクルで失われるようなことは無くても、データ保護キーの有効期限はデフォルトで 90 日だそうですので、有効期限を過ぎると復号に失敗すると思われます (未検証・未確認です)。

Tags: , , , ,

Authentication

ASP.NET Core でのデータ保護キーの管理

by WebSurfer 25. April 2024 19:11

ASP.NET Core Web アプリを IIS でホストするためにアプリケーションプールを作成する際、設定によってはデータ保護キーがリサイクルで失われ、ブラウザから有効な認証クッキー/チケットが送られてきても復号できないので認証に失敗するという話を書きます。元の話は「ASP.NET COREで、Cookie認証が維持できない」です。

復号できず認証失敗

上の画像は認証チケットが期限切れか否かを調べるために自分が作ったページで (コードは下に載せます)、データ保護キーがリサイクルで失われたため認証クッキーを復号できず、例外がスローされた結果です。

ASP.NET Core アプリのデータ保護キーの管理は、Microsoft のドキュメント「ASP.NET Core でのデータ保護のキー管理と有効期間」によると、アプリにより運用環境が検出され、以下のいずれかになるそうです。

  1. アプリが Azure Apps でホストされている場合、キーは %HOME%ASP.NET_DataProtection-Keys フォルダーに保持されます。
  2. ユーザー プロファイルを使用できる場合、キーは %LOCALAPPDATA%\ASP.NET\DataProtection-Keys フォルダーに保持されます。
  3. アプリが IIS でホストされている場合、HKLM レジストリ内の、ワーカー プロセス アカウントにのみ ACL が設定されている特別なレジストリ キーにキーが保持されます。
  4. これらの条件のいずれにも該当しない場合、キーは現在のプロセスの外部には保持されません。 プロセスがシャットダウンすると、生成されたキーはすべて失われます。

(ご参考までに、.NET Framework 版の ASP.NET Web アプリの場合は web.config に配置する machineKey 要素 (ASP.NET 設定スキーマ) に従ったデータ保護キーの管理を行い、デフォルト AutoGenerate, IsolateApps の場合はレジストリにデータ保護キーが保持されます)

ということで、IIS でホストされる場合は上の 2 と 3 が関係することになります。ApplicationPoolIdentity にユーザープロファイルは無いので 2 は関係ないと思っていたのですが、そうではなかったです。以下に 2, 3 それぞれの注意点を述べます。

2. ユーザープロファイルを使用

IIS でホストされた ASP.NET Core アプリを動かすとアプリケーションプール名で c:\users フォルダにフォルダが生成され、上の 2 で言う「ユーザー プロファイルを使用できる」状態になります。

%LOCALAPPDATA% は c:\users\<アプリケーションプール名>\AppData\Local となります。(注: アプリケーションプール名の新規ユーザーが Windows に追加されるわけではなくてフォルダが生成されるだけです)

ただし、フォルダが作成されても、applicationHost.config で setProfileEnvironment 属性が true になってないとその下に ASP.NET\DataProtection-Keys ホルダは作られず、データ保護キーも生成されません。

さらに、[ユーザープロファイルの読み込み]を Ture に設定する必要があります。[ユーザープロファイルの読み込み]が False に設定されていると、データ保護キーが DataProtection-Keys フォルダに存在していても、リサイクル後は認証に失敗します。メモリに保持したデータ保護キーを使うようです。この状態で 3 の条件のレジストリにもキーがある場合はどうなるか未検証です。

上に紹介した Stackoverflow のスレッドの話では、質問者さんの Windows Server 2019 の環境では setProfileEnvironment 属性が true になっていたそうですが、自分の Windows 10 Pro 環境では false になっていました。デフォルトで true だそうですが、applicationHost.config を開いて調べた方が良さそうです。

3. レジストリを使用

アプリケーションプールを作成する際、 [.NET CLR バージョン] を [マネージド コードなし] にするとデータ保護キーはレジストリに保持されることはなく、上の 2 つ目の条件で ASP.NET\DataProtection-Keys ホルダにデータ保護キーが保持されてない限り、リサイクル後はデータ保護キー失われ、有効な認証クッキー・チケットがブラウザから送られてきても認証に失敗します。

解決策は[.NET CLR バージョン]を[.Net CLR バージョン v4.0.30319]にしておく、もしくは、一度ホストする ASP.NET Core アプリを[.Net CLR バージョン v4.0.30319]で動かした後で、 [マネージド コードなし] に設定することです。(注: いろいろ試した結果から分かったことで Microsoft の公式ドキュメントに書いてあることではありません)

.NET CLR バージョンの設定

とにかく一度[.NET CLR バージョン]を[.Net CLR バージョン v4.0.30319]にしてアプリを動かせば、レジストリにデータ保護キーが保持されるようなって、リサイクルでデータ保護キーが失われることはなくなるようです。理由は不明です。

なお、アプリケーションプールの名前はデータ保護キーの保存先のレジストリと関連付けられるようですので注意してください。そして、そのレジストリはアプリケーションプールを削除しても残るようです。レジストリにデータ保護キーを保存できるように設定したアプリケーションプールを一旦削除してから、同じ名前で [.NET CLR バージョン] を [マネージド コードなし] にしてアプリケーションプールを作成しても、リサイクル後の認証は維持できました。

以上、2, 3 それぞれの注意点です。思うに、3 のレジストリの使用に頼らないで、確実に 2 のユーザープロファイルのフォルダのデータ保護キーを使用できるよう設定するのが本筋かもしれません。

なぜなら、Microsoft のドキュメント「ASP.NET Core モジュールと IIS の詳細な構成」の「IIS サイトを作成する」のセクションで、 [マネージド コードなし] に設定することが推奨されていますから。


以下にどのように検証したかを備忘録として残しておきます。環境は上にも書きましたが、自分の Windows 10 Pro の開発マシンのローカル IIS 10.0 です。自分の Windows 10 Pro では applicationHost.config の setProfileEnvironment 属性が false になっており、以下の (8) まではその状態のままですので注意してください。

検証用のアプリは Microsoft のドキュメント「ASP.NET Core Identity を使用せずに cookie 認証を使用する」から入手した .NET 6.0 の Razor Pages アプリに少し手を加えたものです。

(1) C:\WebSites2019 というフォルダ下に AspNetCoreCookieAuth2 という名前のフォルダを作成。

(2) IIS Manager を起動して AspNetCoreCookieAuthTest という名前でアプリケーションプールを新たに作成。その際[アプリケーション プールの編集]ウィンドウで[.NET CLR バージョン]を[マネージド コードなし]に設定。その他はデフォルトのままとしておきます。結果は以下の画像の通りです。

アプリケーショ���プール詳細

(3) IIS Manager のサイトの追加で、上記 (1) のフォルダをサイトに設定。サイト名は AspNetCoreCookieAuth2 とし、アプリケーションプールは上記 (2) で新たに作成したものを設定。

サイトの設定

サイトバインド設定は、種類: http, IP アドレス: 未使用の IP アドレスすべて, ポート: 80, ホスト名: www.aspnetcorecookieauth2.com とします。

サイトバインド

(4) hosts ファイルに 127.0.0.1 www.aspnetcorecookieauth2.com を追加します。

(5) ダウンロードした .NET 6.0 のサンプル Razor Pages プロジェクトを Visual Studio 2022 で開いて以下のように手を加えます。

  • 永続化(認証クッキーを HDD/SSD に保存)するため既存の Login.cshtml.cs の IsPersistent = true, のコメントアウトを解除
  • 認証チケットの有効期限を 1 週間に設定するため Program.cs の .AddCookie のオプションで有効期限を指定
  • 暗号化されている認証チケットを復号し、認証チケットの有効期限の日時を取得するページ AuthExpireCheck を追加。cshtml.cs のコードは以下の通りです(cshtml のコードは省略します)
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Principal;

namespace CookieSample.Pages
{
    public class AuthExpireCheckModel : PageModel
    {
        private readonly CookieAuthenticationOptions _options;

        public AuthExpireCheckModel(IOptions<CookieAuthenticationOptions> options)
        {
            _options = options.Value;
        }

        public string Message { get; set; }

        public void OnGet()
        {
            try
            {
                // デフォルトのクッキー名は ASP.NET Core Identity の
                // .AspNetCore.Identity.Application とは異なり
                // .AspNetCore.Cookies となっているので注意
                string cookie = Request.Cookies[".AspNetCore.Cookies"];
                if (!string.IsNullOrEmpty(cookie))
                {
                    IDataProtectionProvider provider = _options.DataProtectionProvider;
                    IDataProtector protector = provider.CreateProtector(
                        // CookieAuthenticationMiddleware のフルネーム
                        "Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware",
                        // クッキー名 .AspNetCore.Cookies から .AspNetCore.
                        // を削除した文字列
                        "Cookies",
                        // .NET Framework 版は "v1"、Core 版は "v2"
                        "v2");

                    // 認証クッキーから暗号化された認証チケットを復号
                    TicketDataFormat format = new TicketDataFormat(protector);
                    AuthenticationTicket authTicket = format.Unprotect(cookie);

                    // ユーザー名を取得
                    ClaimsPrincipal principal = authTicket.Principal;
                    IIdentity identity = principal.Identity;
                    string userName = identity.Name;

                    // 認証チケットの有効期限の日時を取得
                    AuthenticationProperties property = authTicket.Properties;
                    DateTimeOffset? expiersUtc = property.ExpiresUtc;

                    // ExpiresUtc と現在の時刻を比較して期限切れか否かを判定
                    if (expiersUtc.Value < DateTimeOffset.UtcNow)
                    {
                        Message = $"{userName} 認証チケット期限切れ {expiersUtc.Value}";
                    }
                    else
                    {
                        Message = $"{userName} 認証チケット期限内 {expiersUtc.Value}";
                    }
                }
                else
                {
                    Message = "認証クッキーがサーバーに送信されてきていません。";
                }
            }
            catch (CryptographicException ex)
            {
                Message = $"CryptographicException: {ex.Message}";
            }
            catch (Exception ex)
            {
                Message = $"Exception: {ex.Message}";
            }
        }
    }
}

(6) Microsoft のチュートリアル「IIS に ASP.NET Core アプリを発行する」の「アプリを発行および配置する」セクションに従って、上のステップで作成した C:\WebSites2019\AspNetCoreCookieAuth2 フォルダにプロジェクトを発行。インプロセスホスティングになります (Microsoft 推奨)。

インプロセスホスティング

(7) ブラウザからサイトにアクセスしてログインしてから上の (5) で追加した AuthExpireCheck ページを要求すると以下の通りとなります。一旦ブラウザを閉じて、再度立ち上げ AuthExpireCheck ページを要求しても認証は維持されます。(IsPersistent = true としたことによる Set-Cookie の expires 属性は有効に機能していることが確認できる)

認証は維持されている

(8) IIS Manager を操作してアプリケーションプルをリサイクルし、ブラウザから AuthExpireCheck ページを要求するとこの記事の一番上の画像の結果となります。ブラウザからは上の (7) の操作で入手した認証クッキーをサーバーに送信していますが、リサイクルによってデータ保護キーが失われているので復号できず例外がスローされています。

(9) 自分の Windows 10 Pro 環境では applicationHost.config の setProfileEnvironment 属性は以下の通り false になっています。(Windows Server 2019 では true になっているそうです)

<applicationPoolDefaults managedRuntimeVersion="v4.0">
  <processModel 
    identityType="ApplicationPoolIdentity" 
    loadUserProfile="true" 
    setProfileEnvironment="false" />
</applicationPoolDefaults>

なので、C:\Users\AspNetCoreCookieAuthTest\AppData\Local には ASP.NET\DataProtection-Keys ホルダは作らておれず、データ保護キーも生成されていません。

%LOCALAPPDATA%

setProfileEnvironment を ture に設定し、アプリケーションプール AspNetCoreCookieAuthTest を再起動してから、ASP.NET Core アプリを呼び出すと ASP.NET\DataProtection-Keys フォルダが作られ、その中にデータ保護キーが生成されました。

生成されたデータ保護キー

(10) その後であれば、上の (2) の画像のように[ユーザープロファイルの読み込み]が Ture に設定してあれば、データ保護キーは ASP.NET\DataProtection-Keys フォルダから取得され、リサイクル後も認証は維持されます。

(11) [ユーザープロファイルの読み込み]を False に設定して試してみましたが (Windows Server 2019 では False がデフォルトとのことです)、データ保護キーが DataProtection-Keys フォルダに存在していても、リサイクル後は認証に失敗します。メモリに保持したデータ保護キーを使うようです。

Tags: , , , , ,

CORE

About this blog

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

Calendar

<<  October 2024  >>
MoTuWeThFrSaSu
30123456
78910111213
14151617181920
21222324252627
28293031123
45678910

View posts in large calendar