WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core Identity タイムアウト判定

by WebSurfer 31. October 2020 18:18

先の記事「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;
                    IDataProtector protector = provider.CreateProtector(
                        "Microsoft.AspNetCore.Authentication.Cookies." +
                        "CookieAuthenticationMiddleware",
                        "Identity.Application",
                        "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? expiersUrc = property.ExpiresUtc;

                    // ExpiresUtc と現在の時刻を比較して期限切れか否かを判定
                    if (expiersUrc.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 ページをオーバーライドすることで対応できそうです。

Tags: , , ,

Authentication

ASP.NET Identity タイムアウト判定

by WebSurfer 23. October 2020 18:22

先の記事「Forms 認証のタイムアウト判定」の ASP.NET Identity 版です。(Core ではなく .NET Framework 用の ASP.NET 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 cookieReading 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? expiersUrc = property.ExpiresUtc;
                if (expiersUrc.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>

Tags: , ,

Authentication

MVC5 でのロールによるアクセス制限

by WebSurfer 12. October 2020 15:05

.NET Framework 版の ASP.NET MVC5 アプリで、ユーザーはログインしているが、アクセス権がないコントローラーまたはアクションメソッドにアクセスした場合、以下のようなメッセージを表示する方法を書きます。(Core 3.1 版でのデフォルトです)

AccessDenied

ASP.NET Identity ベースのユーザー認証に、先の記事「ASP.NET Identity のロール管理 (MVC)」のようにしてロールを実装していることが条件です。

.NET Framework 版の ASP.NET MVC5 アプリではコントローラーやアクションメソッドに AuthorizeAttribute 属性を付与し、名前付きパラメータ Roles プロパティにアクセスを許可するロール名を指定することにより、指定されたロールに属するユーザー以外のアクセスを制限することができます。

ただし、デフォルトでは、ユーザーがログイン済みでも指定されたロールに属さない場合、ロールで制限されたページにアクセスすると Login ページにリダイレクトされます。ログイン済なのに Login ページに飛ばされるというのがユーザーフレンドリーではない感じです。

Core 3.1 版の ASP.NET MVC アプリでは、ユーザーがログイン済みだが指定された ロールに属さない場合、Login ページではなくて、上の画像のようなアクセス権がないというメッセージを表示するページにリダイレクトされます。

Core 3.1 版の方がユーザーフレンドリーのように思いますので、.NET Framework 版の MVC5 でも同様なことができないかを考えてみました。方法は以下の 2 つがありそうです。

  1. AuthorizeAttribute クラスを継承したカスタム認証フィルターを定義し、OnAuthorization メソッドを override して使う。
  2. FilterAttribute, IAuthorizationFilter を継承したカスタム認証フィルターを自作してそれを利用する。  

後者の方法で作ったカスタム認証フィルターのコードを以下にアップしておきます。(AccessDenied アクションメソッドとビューの追加も必要ですので忘れないようにしてください。追加しないと 404 エラーになります)

using System;
using System.Web;
using System.Web.Mvc;

namespace Mvc5App2.Filters
{
    public class RoleAuthFilterAttribute : 
        FilterAttribute, IAuthorizationFilter
    {
        private string role;

        public RoleAuthFilterAttribute(string role)
        {
            this.role = role;
        }
        
        public void OnAuthorization(AuthorizationContext filterContext)
        {
            if (filterContext == null)
            {
                throw new ArgumentNullException("filterContext");
            }

            if (!HttpContext.Current.Request.IsAuthenticated)
            {
                string path = "/Account/Login?returnUrl=" + 
                              HttpContext.Current.Request.RawUrl;
                filterContext.Result = new RedirectResult(path);
            }
            else 
            {
                if (!HttpContext.Current.User.IsInRole(this.role))
                {
                    filterContext.Result = 
                        new RedirectResult("/Account/AccessDenied");
                }
            }
        }
    }
}

ASP.NET Identity ベースのユーザー認証にロール管理を実装した場合、ユーザーが認証されているか否か、指定されたロールに属しているか否かはフレームワーク組み込みのプロパティ/メソッドを利用して判定することができます。上のコードの IsAuthenticated プロパティ、IsInRole メソッドがそれです。

独自認証の場合はそのあたりは自力で実装することになります。かなり大変かも。

上のコードの認証フィルターの使い方の例は下の画像の通りです。

認証フィルターの使い方

Visual Studio 2019 のテンプレートを使って作った Home/Contact アクションメソッドに認証フィルター属性を付与して Administrator 以外のアクセスを制限しています。

Tags: , ,

MVC

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  November 2020  >>
MoTuWeThFrSaSu
2627282930311
2345678
9101112131415
16171819202122
23242526272829
30123456

View posts in large calendar