WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

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 画面上でユーザーに通知したものです。

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 < DateTimeOffset.UtcNow)
                {
                    // 知らせるための処置を書く
                    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);

上に書いた 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

ASP.NET MVC の非同期プログラミング

by WebSurfer 4. October 2020 15:50

ASP.NET MVC アプリで async / await を利用した非同期プログラミングで (1) 使用されるスレッドがどのようになるか、(2) Task.Result などを使った同期コードと非同期コードを混在させるとデッドロックは起きるのか、(3) ConfigureAwait メソッドでデッドロックは回避できるのかについて書きます。(.NET Framework アプリの話です。CORE は未確認)

ASP.NET MVC の非同期プログラミング

ちなみに ASP.NET Web Forms アプリ用の HTTP ハンドラで async / await を使って非同期呼び出しをする話は先の記事「非同期 HTTP ハンドラ (2)」に書きましたので興味があればそちらを見てください。

(1) 使用されるスレッド

ASP.NET で非同期プログラミングを行う目的はスレッドプールにある限られた数のスレッドを有効利用しスループットを向上するためです。そこが UI の応答性の向上を目的としている Windows Forms のような GUI アプリと違うところです。

ASP.NET アプリでは Web サーバーがクライアントから要求を受けるとスレッドプールからスレッドを確保して要求を処置します。非同期操作をしなければ、要求を受けてから応答を返すまで最初に確保したスレッドを保持し続けます。

Web アプリでは、外部のデータベースや Web API などにアクセスしてデータを取得するということが多いと思いますが、それに時間がかかる場合は一旦使っていたスレッドはスレッドプールに戻し、データ取得後の処理はスレッドプールから新たにスレッドを取得して行うようにすればスレッドプールのスレッドの有効利用が可能です。

そのあたりの詳細は Microsoft のドキュメント「ASP.NET の非同期/待機の概要」に図解入りで説明されているので見てください。

非同期プログラミングを行うと await 前後で実際にスレッドは違うのかを ASP.NET MVC アプリで試した結果が上の画像です。そのコードは以下の通りです。

public async Task<ActionResult> AsyncTest()
{
    ViewBag.Id1 = "開始時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    ViewBag.Id2 = await TimeCosumingMethod();

    ViewBag.Id3 = "終了時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    return View();
}

private async Task<string> TimeCosumingMethod()
{
    int id = Thread.CurrentThread.ManagedThreadId;

    await Task.Delay(3000);

    return "TimeCosumingMethod の戻り値, ID(IN)=" + id + 
           " / ID(OUT)=" + Thread.CurrentThread.ManagedThreadId;
}

上の画像の ID の数字 (ManagedThreadId) を見てください。TimeCosumingMethod メソッドの await 前後で ManagedThreadId が 6 から 7 に変わっています。ちなみに、Windows Forms のような GUI アプリでは await 前後いずれも UI スレッドになり ManagedThreadId は変わりません。

ASP.NET でも await で待機するときに現在のコンテキストがキャプチャされ、await 完了後はキャプチャしたコンテキストで続きの処理が行われるのは GUI アプリと同様だそうですが、await 前後で同じになるようにしているのはスレッドではなく HttpContext だそうです。それは仕組み上当たり前&そうせざるを得ないと思います。

(2) Task.Result でデッドロック

先の記事「await と Task.Result によるデッドロック」で書いたような Task.Result を使った同期コードと非同期コードを混在させるとデッドロックは起きるでしょうか?

その記事にも少し書きましたが、上のコードの await TimeCosumingMethod() を TimeCosumingMethod().Result に代えるとデッドロックは起きます。そのメカニズムは以下のようなことであろうと思います。

まず、TimeCosumingMethod().Result で 1 つの同期ブロックが待機中となる。呼び出された TimeCosumingMethod メソッドの await で待機する際にキャプチャされた「現在のコンテキスト」で await 完了後の同期処理を実行しようとする。しかし「現在のコンテキスト」では一度に実行するコードは 1 つのチャンクに限定されている。なので、Result プロパティでの待機が終わるまで await 完了後の同期処理は実行できない。結果デッドロックになる。

(3) ConfigureAwait でデッドロック回避

先の記事「ConfigureAwait によるデッドロックの回避」で書いたように、await 完了後の同期処理を実行するのに、await で待機する際にキャプチャした「現在のコンテキスト」ではなく、別の���ンテキストで行えばデッドロックにはなりません。

ConfigureAwait メソッドの使用

以下のコードのように ConfigureAwait(false) を追加することにより、await 完了後の残り処理は、キャプチャしたコンテキストではなく、スレッドプールのコンテキストで処理されるのでデッドロックは回避でき、上の画像のとおり実行が完了します。

public async Task<ActionResult> AsyncTest()
{
    ViewBag.Id1 = "開始時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    ViewBag.Id2 = TimeCosumingMethod().Result;

    ViewBag.Id3 = "終了時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    return View();
}

private async Task<string> TimeCosumingMethod()
{
    int id = Thread.CurrentThread.ManagedThreadId;

    // ConfigureAwait(false) を追加するとデッドロックは回避できる
    await Task.Delay(3000).
        ConfigureAwait(continueOnCapturedContext: false);

    return "TimeCosumingMethod の戻り値, ID(IN)=" + id + 
           " / ID(OUT)=" + Thread.CurrentThread.ManagedThreadId;
}

ただし、スレッドは await 前後で同じになります。ということは、要求を受けた時に確保したスレッドを応答を返すまでずっと使い続けていたということで、スレッドの有効利用という ASP.NET の非同期の目的は果たせてないようです。

await 前後でスレッドが異なる場合は、await 前にキャプチャしたコンテキストを await 後でも使わないと HttpContext が渡せないが、continueOnCapturedContext: false ではそれができないので同じスレッドを使い続けざるを得ないということではないかと思います。

Tags: , , ,

MVC

About this blog

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

Calendar

<<  October 2020  >>
MoTuWeThFrSaSu
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar