WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

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

連動ドロップダウンリスト (MVC5 版)

by WebSurfer 22. August 2020 14:53

ASP.NET MVC での連動ドロップダウンリストの実装方法を備忘録として書いておきます。

ASP.NET MVC の連動 DropDownList

ASP.NET Web Forms アプリの場合は先の記事「DetailsView 中の連動 DropDownList」に書きました。それと同様な連動機能を MVC アプリで実装してみます。

記事「DetailsView 中の連動 DropDownList」と同様に、Northwind サンプルデータベースの Categories テーブルと Products テーブルを使って例を書きます。

上の画像に示すように 2 つのドロップダウンリストを配置し、1 つめのドロップダウンリストで分類を選択すると、2 つめのドロップダウンリストにはその分類に属する製品が絞り込まれて表示されるようにします。

データベースへのアクセスやデータの取得は先の記事「スキャフォールディング機能」のステップ 1 ~ 10 で書きました Entity Data Model (EDM) を利用します。Categories テーブルと Products テーブルの EDM ダイアグラムは以下の通りです。

Categories  テーブルと Products テーブル

記事「DetailsView 中の連動 DropDownList」の場合は、分類ドロップダウンリストの選択が変更されるとポストバックがかかり、サーバー側で変更に応じて製品ドロップダウンリストの内容を書き換えてページ全体を再描画するというものでした。いちいちポストバックして再描画というのがちょっとやりすぎの感があります。

Ajax Control Toolkit の中に CascadingDropDown というものがありますが、こちらは Ajax を利用して、上位ドロップダウンリストの変更があると、サーバー側の Web サービスに下位ドロップダウンリストに表示するデータを要求し、Web サービスから戻ってきた JSON 形式のデータで下位ドロップダウンリストの内容を書き換えるというものです。

この記事の MVC 版連動ドロップダウンリストでも、Ajax Control Toolkit の CascadingDropDown のやり方にならって、jQuery ajax を利用して、分類ドロップダウンリストの選択に応じて製品ドロップダウンリストに表示する ProductID と ProductName を JSON 形式で取得し、製品ドロップダウンリストの内容を書き換えるようにしました。

コードは以下の通りです。説明はコード内にコメントとして書きましたのでそちらを見てください。手抜きでスミマセン。

Model

using System.ComponentModel.DataAnnotations;

namespace Mvc5App.Models
{
    public class Sales
    {
        public int Id { get; set; }

        [Display(Name = "分類")]
        [Required(ErrorMessage = "{0} の選択は必須")]
        public int CategoryID { get; set; }

        [Display(Name = "製品")]
        [Required(ErrorMessage = "{0} の選択は必須")]
        public int ProductID { get; set; }

        [Display(Name = "コメント")]
        [Required(ErrorMessage = "{0} は必須")]
        public string Comment { get; set; }
    }
}

Controller / Action Method

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web;
using System.Web.Mvc;
using Mvc5App;

namespace Mvc5App.Controllers
{
    public class ProductsController : Controller
    {
        private NORTHWINDEntities db = new NORTHWINDEntities();

        public ActionResult CascadingDropDown()
        {
            ViewBag.CategoryID = 
                new SelectList(db.Categories, "CategoryID", "CategoryName");
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult CascadingDropDown(
            [Bind(Include = "ProductID,CategoryID,Comment")] 
            Mvc5App.Models.Sales sales)
        {
            if (ModelState.IsValid)
            {
                return RedirectToAction("Index");
            }

            // 検証結果 NG で再描画する際は以下のように再度 ViewBag
            // に SelectList を設定しないと分類ドロップダウンの中身
            // が表示されないので注意
            ViewBag.CategoryID = 
                new SelectList(db.Categories, "CategoryID", "CategoryName");
            return View(sales);
        }

        // 製品ドロップダウンに表示する ProductID と ProductName を
        // JSON 形式で取得するアクションメソッド。引数 id が分類ドロ
        // ップダウンで選択された CategoryID
        public async Task<ActionResult> GetProducts(int id)
        {
            var products = from p in db.Products
                           where p.CategoryID == id
                           select new
                           {
                               ProductID = p.ProductID,
                               ProductName = p.ProductName
                           };
            return Json(await products.ToListAsync(), 
                        JsonRequestBehavior.AllowGet);
        }
    }
}

View

@model Mvc5App.Models.Sales

@{
    ViewBag.Title = "CascadingDropDown";
}

<h2>CascadingDropDown</h2>


@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()

    <div class="form-horizontal">
        <h4>Sales</h4>
        <hr />
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })
        <div class="form-group">
            @Html.LabelFor(model => model.CategoryID, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.DropDownList("CategoryID", null, 
                    "▼分類を選択してください▼", 
                    htmlAttributes: new { @class = "form-control" })
                @Html.ValidationMessageFor(model => model.CategoryID, 
                    "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.ProductID, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                <select class="form-control" id="ProductID" name="ProductID"></select>
                @Html.ValidationMessageFor(model => model.ProductID, 
                    "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.Comment, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Comment, 
                    new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.Comment, 
                    "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </div>
    </div>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")

    <script type="text/javascript">
        //<![CDATA[

        // 長くなるので JavaScript / jQuery のコードは下に別掲

        //]]>
    </script>
}

JavaScript / jQuery

実際は以下のコードは View の @section Scripts { ... } ブロックの中にインラインで書いています。View のコードが長くなって見難くなるので別掲にしました。

$(function () {
    // 分類ドロップダウンの jQuery オブジェクトを取得。
    // html コードの id は html ヘルパの Id メソッドで取得
    var categoryDDL = $('#@Html.Id("CategoryID")');

    // 製品ドロップダウンの jQuery オブジェクトを取得。
    // こちらは <select id="ProductID" とハードコーディン
    // グされているので以下で OK (Id メソッドは不要)
    var productDDL = $("#ProductID");

    // DropDownList の第 3 引数 "▼分類を選択してください▼"
    // は以下の option 要素になる (必ず value="" になると書
    // いた Microsoft の文書が見つからないのが不安要素):
    // <option value="">▼分類を選択してください▼</option>

    // 以下は初期画面および検証結果 NG での再描画の際必要。
    // 初期画面では categoryDDL.val() は上の option 要素の
    // value="" を取得するので if 文の条件は false となる。
    // 検証結果 NG で再描画される際は分類ドロップダウンの
    // 選択に応じて製品ドロップダウンの内容を書き換える
    if (categoryDDL.val() != "") {
        productDDL.children().remove();
        productDDL.append(
            '<option value="">▼製品を選択してください▼</option>');
        productDDL.removeAttr('disabled');

        // 分類ドロップダウンの選択結果 (CategoryID) を
        // jQuery ajax でサーバー側の GetProducts アクション
        // メソッドに送信。アクションメソッドは Products テ
        // ーブルに SELECT クエリを発行し、CategoryID に属す
        // 製品の ProductID, ProductName を取得して JSON 文
        // 字列として返す。その JSON 文字列から option 要素
        // の文字列を組み立てて製品ドロップダウンに追加する
        $.ajax({
            url: '/Products/GetProducts/' + categoryDDL.val(),
            method: 'get',
        }).done(function (data) {
            $.each(data, function (key, val) {
                productDDL.append('<option value=' +
                    val.ProductID + '>' + val.ProductName +
                    '</option>');
            });
        }).fail(function (jqXHR, textStatus, errorThrown) {
            alert('Error getting products!');
        });
    } else {
        productDDL.children().remove();
        productDDL.attr('disabled', 'disabled');
    }

    // 分類ドロップダウンの選択が変更されると change イベント
    // が発生 するのでそのリスナで製品ドロップダウンの内容を
    // 分類に応じて書き換え。リスナの中身は上と同じコード
    categoryDDL.on("change", function () {
        if (categoryDDL.val() != "") {
            productDDL.children().remove();
            productDDL.append(
                '<option value="">▼製品を選択してください▼</option>');
            productDDL.removeAttr('disabled');

            $.ajax({
                url: '/Products/GetProducts/' + categoryDDL.val(),
                method: 'get',
            }).done(function (data) {
                $.each(data, function (key, val) {
                    productDDL.append('<option value=' +
                        val.ProductID + '>' + val.ProductName +
                        '</option>');
                });
            }).fail(function (jqXHR, textStatus, errorThrown) {
                alert('Error getting products!');
            });
        } else {
            productDDL.children().remove();
            productDDL.attr('disabled', 'disabled');
        }
    });
});

このコードで、この記事の上の画像のとおり表示されます。

分類ドロップダウンリストの選択変更に連動して製品ドロップダウンリストの内容が変わります。送信ボタンクリックの際のドロップダウンリストの検証は、クライアント側ではかかりませんが、サーバー側では検証されてエラーメッセージは期待通り表示されます。

サーバー側での検証結果が NG の場合、同じ画面が再描画されユーザーに選択の修正を促すようにしていますが、その際のドロップダウンリストの連動も OK なことを確認しています。

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