WebSurfer's Home

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

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

by WebSurfer 2024年4月25日 19:11

ASP.NET Core Web アプリを IIS でホストするためにアプリケーションプールを作成する際、以下の画像のように[.NET CLR バージョン]を[マネージド コードなし]にするとデータ保護キーがリサイクルで失われ、ブラウザから有効な認証クッキー/チケットが送られてきても復号できないので認証に失敗するという話を書きます。(元の話は「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 でホストされる場合は上の 3 つ目に該当することになり、データ保護キーはレジストリに保存されリサイクルで失われことはない・・・はずですが、上の画像のように[.NET CLR バージョン]を[マネージド コードなし]に設定してアプリケーションプールを作成するとデータ保護キーはレジストリに保存されないようでリサイクルで失われます。なので、リサイクル後はブラウザから有効な認証クッキー/チケットが送られてきても認証に失敗します。(自分の Windows 10 Pro の開発マシンのローカル IIS 10.0 での検証結果です。Windows Server の IIS では少し様子が違うという話がありますが、リサイクルでキーが失われる問題はあるそうです)

知ってました? 自分は知らなかったです。(汗) ASP.NET Core Identity のデフォルトの認証チケットの有効期限は 5 分ですし、短い方がセキュリティ的に望ましいし、短くしていると気が付かないかもしれません。(言い訳)

Microsoft のドキュメント「ASP.NET Core モジュールと IIS の詳細な構成」の「IIS サイトを作成する」のセクションに、

"6. [アプリケーション プールの編集] ウィンドウで、 [.NET CLR バージョン] を [マネージド コードなし] に設定します。"

と書いてあり、 [マネージド コードなし] に設定するのは必須ではないものの推奨されています。ということは、Microsoft の推奨に従ってアプリケーションプールを設定すると、リサイクルで認証が維持できないということになります。

解決策は、アプリケーションプールの追加を行う際、下の画像のように[.NET CLR バージョン]を[.Net CLR バージョン v4.0.30319]にしておくことです。(注: いろいろ試した結果から分かったことで Microsoft の公式ドキュメントに書いてあることではありません)

.NET CLR バージョンの設定

Micosoft の推奨に従って [.NET CLR バージョン] を [マネージド コードなし] に設定したいということであれば、設定を[.Net CLR バージョン v4.0.30319]のままにしておいて一度ホストする ASP.NET Core アプリを動かした後で、 [マネージド コードなし] に設定します。

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

バグっぽいのでそのうち修正されるかもしれませんが、修正されたことがはっきりするまでは要注意だと思います。

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

また、上に紹介した Stckoverflow の質問者さんの環境では、[ユーザープロファイルの読み込み]の設定が True / False で違いがあって、True の場合は問題ないとのことです。自分が試した限りでは True でも False でも関係なくダメで、リサイクル後の認証は維持できませんでした。想像ですが、上に書いた 2 つ目の条件の「ユーザー プロファイルを使用できる場合」に該当していたのではないかと思われます。

以下にどのように検証したかを備忘録として残しておきます。環境は上にも書きましたが、自分の Windows 10 Pro の開発マシンのローカル IIS 10.0 です。アプリは 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) の操作で入手した認証クッキーをサーバーに送信していますが、リサイクルによってデータ保護キーが失われているのでユーザー認証に失敗しています。

認証されてない

Tags: , , , , ,

CORE

CORS 非対応の場合のエラーメッセージ

by WebSurfer 2024年4月2日 16:33

ブラウザの fetch API を使ってドメインが異なる Web API などに要求を出した時、Web API 側が CORS に対応していない場合のブラウザのエラーメッセージや Fiddler で見た時の要求・応答がどのようになるかを書きます。

検証に使ったのは、先の記事「Web API に CORS 実装 (CORE)」に書いた ASP.NET Core Web API アプリで、Web API の CORS を無効にして、ドメインが異なる MVC アプリから fetch API を使って Web API に要求を出ました。ブラウザは Windows 10 の Chrome 123.0.6312.86 と Edge 123.0.2420.65 です。

ブラウザのエラーメッセージは、ディベロッパーツールの Console タブを開くと見ることができ、Web API 側が CORS 非対応では以下のようになるはずです。赤枠が「単純リクエスト」の場合で、青枠が「プリフライトリクエスト」の場合です。(注: 「単純リクエスト」というのは古い CORS 仕様書の用語で、現在 CORS を定義している Fetch 仕様書 ではその用語を使用していないそうです)

ブラウザのエラーメッセージ

エラーメッセージを以下にコピペしておきます。文章中の 'https://localhost:44371/api/values' が Web API 側で 'https://localhost:44343' がブラウザ側です。

「単純リクエスト」の場合

Access to fetch at 'https://localhost:44371/api/values' from origin 'https://localhost:44343' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

「プリフライトリクエスト」の場合

Access to fetch at 'https://localhost:44371/api/values' from origin 'https://localhost:44343' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

「プリフライトリクエスト」の場合は "Response to preflight request doesn't pass access control check:" という文が追加されています。その文の有無で「プリフライトリクエスト」か否かが分かるはずです。

蛇足ですが、エラーメッセージに含まれる "If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled." を見て mode を no-cors に設定してもほとんどの場合解決にはなりませんので注意してください。詳しくは先の記事「fetch API の mode:"no-cors"」を見てください。

「単純リクエスト」と「プリフライトリクエスト」が必要になる場合の違いについて簡単に書いておきます。「単純リクエスト」になるのは、メソッドが GET, HEAD, POST のいずれかで、かつ、要求ヘッダが「CORS-safelisted request header (CORS セーフリストリクエストヘッダー)」の場合です。それ以外は「プリフライトリクエスト」が必要になります。(詳しくは、MDN のドキュメント「オリジン間リソース共有 (CORS)」を見てください)

例えば、JSON 文字列をコンテンツに含めて POST 送信する場合は要求ヘッダに Content-Type: application/json を含めると思いますが、その場合は「プリフライトリクエスト」が必要になります。


Fiddler で見た時の要求・応答は以下のようになります。CORS の要求側はブラウザの仕事で、プリフライトが必要か否かもブラウザが判断して、下の画像の赤枠で示した CORS に必要な要求を出してくれます。

「単純リクエスト」の場合

ブラウザは Origin を要求ヘッダに含めて送信しますが、応答ヘッダに Access-Control-Allow-Origin が含まれないということででエラーとなっています。

「単純リクエスト」の場合

「プリフライトリクエスト」の場合

OPTIONS メソッドを使って CORS に必要なヘッダを要求に含めて出しています。下の画像の赤枠部分を見てください。しかし、応答ヘッダに Access-Control-Allow-Origin が含まれないということでエラーとなっています。

「プリフライトリクエスト」の場合

上の「プリフライトリクエスト」の場合の画像で、応答が 405 Method Not Allowed、Allow: GET, POST となっています。これは ASP.NET Core Web API による応答と思われますが、なぜ Method Not Allowed となるのか、GET, POST でないとダメと言われるのかは調べ切れてません。想像ですが、CORS を有効にしないと OPTIONS メソッドが許可されないということではないかと思われます。


参考に、Web API 側が CORS に対応していて、ブラウザ側でデータの取得に成功する場合の要求・応答を Fiddler で見た画像も下に貼っておきます。

「単純リクエスト」の場合

単純リクエスト

「プリフライトリクエスト」の場合

Web API が CORS に対応しているので「プリフライトリクエスト」の応答ヘッダに Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Allow-Origin が含まれて返ってきます。

プリフライトリクエスト

ブラウザはそれを見て再度要求を出しデータを取得します。

データの取得

Tags: , , ,

JavaScript

ListView に thead と tbody 追加

by WebSurfer 2024年4月1日 13:11

ASP.NET Web Forms アプリ用の ListView コントロールからレンダリングされる html ソースの table 要素内に thead, tbody 要素を追加する方法を書きます。

Visual Studio のデザイナを使って ListView のテーブル形式のコードを自動生成させると aspx ファイルに以下のような LayoutTemplate が生成されます。まず、それに赤枠と青枠で示したような thead, tbody 要素を追記します。

aspx ファイル

さらに、自動生成されたコードでは親の table 要素に runat="server" 属性が付与されているのでそれを削除する必要があります。上の画像のコメントを見てください。

runat="server" 属性が付与されているとその要素はサーバーコントロールになります。親の table 要素がサーバーコントロールになると、ASP.NET が html ソースをレンダリングする際に追加した thead, tbody 要素は削除されてしまいますので。

結果の html ソースは以下のようになります。

結果の html ソース

runat="server" 属性を削除するとサーバー側のコードでその要素を操作(例えばプロパティを動的に設定するなど)できなくなりますが、それ以外の不都合はないと思います。

GridView の例は別の記事「GridView と thead, tbody, tfoot」に書きましたのでそちらを見てください。

Tags: , ,

ASP.NET

About this blog

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

Calendar

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

View posts in large calendar