先の記事「Forms 認証のタイムアウト判定」の ASP.NET Identity 版です。(Core ではなく .NET Framework 用の ASP.NET Identity です。Core 3.1 版は別の記事「ASP.NET Core Identity タイムアウト判定」に書きました)
Visual Studio 2019 などのテンプレートを使って ASP.NET Web アプリのプロジェクトを作る際、認証を「個別のユーザーアカウント」に設定すると ASP.NET Identity を利用したユーザー認証が実装されます。
基本的には旧来のフォーム認証と同様で、ログイン後はクッキーに認証チケットを入れて要求毎にサーバーに送信するので認証状態が継続されるという仕組みになっています。
そこで、一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたまま長時間席を外すなどして、タイムアウトに設定した時間を超えてアクセスしなかった場合を考えてください。
ユーザーが席に戻ってきて再度アクセスした場合、アクセスしたページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。
その際、ユーザーに認証チケットが期限切れとなっていることを知らせるためにどのようなことができるかということを書きます。
User.Identity.IsAuthenticated ではダメです。認証されているか否かは判定できますが、認証チケットが期限切れになったのか、それとも最初からログインしてなかったのかはわかりませんから。
一旦認証を受けたが、認証チケットが期限切れになっているというのは、Web サーバー側では以下の条件で判定できるはずです。
-
要求 HTTP ヘッダーに認証クッキーが含まれる。
-
認証クッキーの中の認証チケットが期限切れ。
認証クッキーと認証チケットは違うことに注意してください。クッキーはチケットの入れ物に過ぎません。認証チケットの有効期限は認証クッキーの Value の中に入っている情報の一つです。HttpCookie.Expires ではありません。
一般的に、一旦認証クッキーの発行を受ければ、ブラウザを閉じない限り次の要求の時にブラウザはサーバーにクッキーを送ります。(注:認証クッキーを「永続化」している場合は話が違ってきます。詳しくは別の記事「Froms 認証クッキーの永続化」を見てください)
Web サーバーが認証クッキーを取得できれば、その Value を復号して認証チケットを取得し、期限切れか否かの情報を取得できます。それで上記の 2 つの条件を確認できます。
しかし、認証クッキーを復号する方法が旧来のフォーム認証の場合とは全く異なっているのが問題でした。そこは、ググって調べた記事
ASP.NET Identity 2.0 decrypt Owin cookie と Reading Katana Cookie Authentication Middleware’s Cookie from FormsAuthenticationModule を参考にさせていただきました。
記事のコードでは MemoryStream などを使って ClaimsIdentity などを取得していますが、前者の記事のコメント欄に書いてあるように TicketSerializer クラスの Deserialize メソッドを使って AuthenticationTicket オブジェクトを取得し、それから必要な情報を得る方が良さそうです。
と言うより、認証チケットが有効期限切れかどうかは、AuthenticationTicket.Properties で取得できる AuthenticationProperties の ExpiresUtc プロパティをチェックしないと分からないので、そうせざるを得ないようです。
コードは以下のように HTTP モジュールとして実装してみました。上の画像がこのモジュールにより認証チケットの期限切れをチェックして Login 画面上でユーザーに通知したものです。
ただし、ユーザーへの通知を下のコードの最後の方にあるように Response.Write を使って書き込んだ場合、見かけは上の画像のようになりますが、実は <html></html> タグの外(この例では先頭)に書き込まれています。
なので、ユーザーへの通知が必要なら、HTTP モジュールで通知を行うのではなく、Login ページに下のようなコードを含めて判定し、ページの所定の位置に書き込むようにする方がよさそうです。
using System;
using System.Linq;
using System.Web;
using System.Security.Claims;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.DataHandler.Serializer;
namespace Mvc5ProfileInfo.Modules
{
public class MyHttpModule : IHttpModule
{
// IHttpModule に Dispose メソッドが含まれるので定義が必要
public void Dispose()
{
}
public void Init(HttpApplication context)
{
context.AuthenticateRequest += Context_AuthenticateRequest;
}
private void Context_AuthenticateRequest(object sender, EventArgs e)
{
HttpRequest request = HttpContext.Current.Request;
// 認証チケットの期限が切れた後、認証が必要なページにアクセス
// して Login ページにリダイレクトされた時のみ対応します。
// "/Account/Login" は実際に合わせて適宜変更してください
if (request.CurrentExecutionFilePath != "/Account/Login")
{
return;
}
// 認証クッキーが送られてくる時のみ対応します。クッキー名の
// .AspNet.ApplicationCookie はデフォルト。必要あれば変更
HttpCookie cookie = request.Cookies.Get(".AspNet.ApplicationCookie");
if (cookie == null)
{
return;
}
// (注1)・・・下の注記参照
string ticket = cookie.Value;
ticket = ticket.Replace('-', '+').Replace('_', '/');
int padding = 3 - ((ticket.Length + 3) % 4);
if (padding != 0)
{
ticket = ticket + new string('=', padding);
}
byte[] bytes = Convert.FromBase64String(ticket);
// (注2)・・・下の注記参照
bytes = System.Web.Security.MachineKey.Unprotect(bytes,
"Microsoft.Owin.Security.Cookies.CookieAuthenticationMiddleware",
"ApplicationCookie", "v1");
TicketSerializer serializer = new TicketSerializer();
AuthenticationTicket authTicket = serializer.Deserialize(bytes);
// 以下いろいろ書いてあるが必要なのは AuthenticationProperties
// オブジェクトの ExpiresUtc プロパティで取得できる有効期限。
// 他はその他にもいろいろ情報が取得できることを示したのみ
ClaimsIdentity identity = authTicket.Identity;
AuthenticationProperties property = authTicket.Properties;
if (identity != null && property != null)
{
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
// クッキーが送られてくると有効期限が切れていても
// ClaimsIdentity.IsAuthenticated は true になる
bool isAuthenticated = identity.IsAuthenticated;
string UserName = identity.Name;
string authType = identity.AuthenticationType;
// 追加したプロファイル情報 HandleName の取得
Claim claim = identity.Claims.
FirstOrDefault(c => c.Type == ClaimTypes.GivenName);
string handleName = claim.Value;
// ExpiresUtc と現在の時刻を比較して期限切れか否かを判定
DateTimeOffset? expiersUtc = property.ExpiresUtc;
if (expiersUtc.Value < DateTimeOffset.UtcNow)
{
// 知らせるための処置を書く
// 以下のコードでは <html></html> 外に書き込まれるので注意
HttpContext.Current.Response.Write("<h1><font color=red>" +
handleName +
" さん、認証チケットの有効期限が切れています" +
"</font></h1><hr>");
}
}
}
}
}
(注 1) 認証チケットのバイト列をクッキーに設定する際 Base64 でエンコードされますが、その際 URL アプリケーションのための変形が行われ '+' は '-' に、'/' は '_' に変換され、パディング '=' は除去されます。エンコードには ITextEncoder.Encode Method (Byte[]) が使われているようです。なので、そのデコードには ITextEncoder.Decode Method (String) を使えば文字列を変換するコードを書かなくてもバイト列を取得できます。具体的には以下の通りです(検証済み)。
byte[] bytes = Microsoft.Owin.Security.DataHandler.Encoder.
TextEncodings.Base64Url.Decode(cookie.Value);
(注 2) 認証チケットのバイト列は MachineKey.Protect(Byte[], String[]) メソッドを使って暗号化されているようです。復号するには MachineKey.Unprotect(Byte[], String[]) メソッドを使いますが、その際、第 2 引数には暗号化するときに Protect メソッドで使用した値と同じものを設定する必要があります(違うと CryptographicException がスローされます)。
では、その第 2 引数は何になるかですが、自分が探した限りでは Microsoft の公式文書は見つかりませんでした。ググって見つかった記事の中で一番詳しかったのが「ASP.NET Cookie是怎么生成的」で、それによると以下のようにするのと同じになるようです(検証済み)。
string purpose1 = typeof(Microsoft.Owin.Security.Cookies.
CookieAuthenticationMiddleware).FullName;
string purpose2 = Microsoft.AspNet.Identity.
DefaultAuthenticationTypes.ApplicationCookie;
string purpose3 = "v1";
bytes = System.Web.Security.MachineKey.Unprotect(bytes,
purpose1, purpose2, purpose3);
(注 3)上に書いた HTTP モジュールを動かすには web.config での登録が必要です。その具体例は以下の通りです。
<system.webServer>
<modules>
<add name="MyHttpModule" type="Mvc5ProfileInfo.Modules.MyHttpModule"/>
</modules>
</system.webServer>