ASP.NET Core Web アプリを IIS でホストするためにアプリケーションプールを作成する際、以下の画像のように[.NET CLR バージョン]を[マネージド コードなし]にするとデータ保護キーがリサイクルで失われ、ブラウザから有効な認証クッキー/チケットが送られてきても復号できないので認証に失敗するという話を書きます。(元の話は「ASP.NET COREで、Cookie認証が維持できない」です)
ASP.NET Core アプリのデータ保護キーの管理は、Microsoft のドキュメント「ASP.NET Core でのデータ保護のキー管理と有効期間」によると、アプリにより運用環境が検出され、以下のいずれかになるそうです。
-
アプリが Azure Apps でホストされている場合、キーは %HOME%ASP.NET_DataProtection-Keys フォルダーに保持されます。
-
ユーザー プロファイルを使用できる場合、キーは %LOCALAPPDATA%\ASP.NET\DataProtection-Keys フォルダーに保持されます。
-
アプリが IIS でホストされている場合、HKLM レジストリ内の、ワーカー プロセス アカウントにのみ ACL が設定されている特別なレジストリ キーにキーが保持されます。
-
これらの条件のいずれにも該当しない場合、キーは現在のプロセスの外部には保持されません。 プロセスがシャットダウンすると、生成されたキーはすべて失われます。
(ご参考までに、.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 の公式ドキュメントに書いてあることではありません)
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) の操作で入手した認証クッキーをサーバーに送信していますが、リサイクルによってデータ保護キーが失われているのでユーザー認証に失敗しています。