先の記事「ASP.NET Identity タイムアウト判定」の Core 3.1 版です。(先の記事は .NET Framework 版です。Core 版とはかなりの部分で異なります)
ASP.NET Identity の認証システムは、基本的に旧来のフォーム認証と同様で、ログイン後はクッキーに認証チケットを入れて要求毎にサーバーに送信するので認証状態が維持されるという仕組みになっています。
一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたままタイムアウトとなって、再度どこかのページを要求したとすると、要求したページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。
その時、認証チケットが期限切れになった(認証されているか否かではありません)というのを、どのようすれば判定できるかということを書きます。
Web サーバー側では以下の条件で判定できるはずです。
-
要求 HTTP ヘッダーに認証クッキーが含まれる。
-
認証クッキーの中の認証チケットが期限切れ。
Web サーバーが認証クッキーを取得できれば、それを復号して認証チケットを取得し、チケットの中の有効期限の日時の情報を取得できます。それで上記の 2 つの条件を確認できます。
認証クッキーを復号する方法ですが、旧来のフォーム認証の場合とはもちろん、.NET Framework 版の ASP.NET Identity の場合ともかなり異なっています。
そのあたりはググって調べた stackoverflow の記事 How to decrypt .AspNetCore.Identity.Application cookie in ASP.NET Core 3.0? と How to manually decrypt an ASP.NET Core Authentication cookie? の回答を参考にさせていただきました。
それを先の記事と同様に HTTP モジュールとして実装してみようと思いましたが、Core には HTTP モジュールも HTTP ハンドラも web.config もないとのこと。
.NET Framework の ASP.NET の HTTP モジュールに代わるものが ASP.NET Core ではミドルウエアになるらしいです。なので、以下の Microsoft のドキュメントを参考に、ミドルウェアを作ってみました。
以下がそのミドルウェアのコードです。上の画像がこのミドルウェアにより認証チケットの期限切れをチェックして Login 画面上でユーザーに通知したものです。
ただし、ユーザーへの通知を下のコードの最後の方にあるように HttpResponse.WriteAsync を使って書き込んだ場合、見かけは上の画像のようになりますが、実は <html></html> タグの外(この例では最後)に書き込まれています。また、上の記事「ASP.NET Core のミドルウェア」の警告に書いてありますが、next.Invoke の前後で応答本文に書き込むというのはそもそもやってはいけないことのようです。
今回はミドルウェアに実装してしまいましたが、ユーザーへの通知を行うならミドルウェアでなく Login ページに下のコードを実装して期限切れを判定し、所定の位置に書き込むようにする方がよさそうです。
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using System.Security.Principal;
namespace MvcCoreApp.Middleware
{
public class AuthExpireCheckMiddleware
{
private readonly RequestDelegate _next;
private readonly CookieAuthenticationOptions _options;
// コンストラクタの引数を以下のように設定しておくことにより
// CookieAuthenticationOptions を DI 機能により取得できる
public AuthExpireCheckMiddleware(
RequestDelegate next,
IOptions<CookieAuthenticationOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
// ここでの処理は必要ないので即後続のミドルウェアに処理を渡す。
// 応答がクライアントに送信された後に _next.Invoke を呼び出すと
// 例外がスローされるそうなので注意(ここで Response.WriteAsync
// は書けない)
await _next.Invoke(context);
// 認証チケットの期限が切れた後、認証が必要なページにアクセス
// して Login ページにリダイレクトされた時のみ対応。
// "/Identity/Account/Login" は Visual Studio のテンプレートで
// プロジェクトを作った際のデフォルト。必要あれば変更可
if (context.Request.Path.ToString().
Contains("/Identity/Account/Login"))
{
// 認証クッキーが送られてくる時のみ対応。クッキー名
// .AspNetCore.Identity.Application はデフォルト。
// 必要あれば変更可
string cookie
= context.Request.Cookies[".AspNetCore.Identity.Application"];
if (!string.IsNullOrEmpty(cookie))
{
IDataProtectionProvider provider =
_options.DataProtectionProvider;
// 下の「注2」参照
IDataProtector protector = provider.CreateProtector(
// CookieAuthenticationMiddleware のフルネームらしい
"Microsoft.AspNetCore.Authentication.Cookies." +
"CookieAuthenticationMiddleware",
// クッキー名から .AspNetCore. を削除した文字列
"Identity.Application",
// .NET Framework 版は "v1"
"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)
{
// ユーザーに通知
// 以下のコードでは <html></html> 外に書き込まれるので注意
await context.Response.WriteAsync("<h3><font color=red>" +
userName +
" さん、認証チケットの有効期限が切れています" +
"</font></h3>");
}
}
}
}
}
}
注1
上のコードのように DI 機能により CookieAuthenticationOptions オブジェクトを取得できます。それをベースに復号に必要な TicketDataFormat オブジェクトを取得し、認証クッキーから認証チケットを復号しています。
Visual Studio のテンプレートを使って「個別のユーザーアカウント」を認証に選んで作成したプロジェクトでは以下のコードが Startup.cs 含まれています。その場合、コンストラクタの引数に IOptions<CookieAuthenticationOptions> options を含めるだけで DI 機能が働きます。コントローラーのコンストラクタでも同じです。
services.AddDefaultIdentity<IdentityUser>(options =>
options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
上の設定が Startup.cs に含まれない場合、上に紹介した stackoverflow の記事 How to decrypt .AspNetCore.Identity.Application cookie in ASP.NET Core 3.0? にあるようなサービスの登録が必要です、
注2
IDataProtectionProvider.CreateProtector の引数ですが、これは .NET Framework の MachineKey.Protect / MachineKey.Unprotect の第 2 引数と同様に暗号化テキストを特定の目的にロックするためのもののようです。
上の紹介した stackoverflow の記事に少し記述があります。Microsoft のドキュメントでは「ASP.NET アプリ間での認証の共有」に記載があるのを見つけました。
注3
上に書いたミドルウェアが動くようにするには、Startup.cs の Configure メソッドでの登録が必要です。その具体例は以下の通りです。場所は既存の app.UseEndpoints の直前で良いです。
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ・・・中略・・・
// これを追記
app.UseMiddleware<MvcCoreApp.Middleware.AuthExpireCheckMiddleware>();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapRazorPages();
});
}
注4
上にも書きましたが、next.Invoke の前後で応答本文に書き込むというのはそもそもやってはいけないことのようですし(特に next.Invoke の前はエラーになるとのこと)、 <html></html> タグの外に書き込まれてしまうので、ユーザーへの通知を行うならミドルウェアではなく Login ページ内で行うべきと思います。
認証に「個別のユーザーアカウント」を選んでプロジェクトを作成すると Login, Register 他の認証関係の機能は Razor Class Library (RCL) として提供され、ソースコードはプロジェクトには含まれないというところが問題ですが、スキャフォールディング機能を利用して Login ページをオーバーライドすることで対応できそうです。