WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Controller で作成された匿名型は View でアクセス不可

by WebSurfer 11. October 2024 15:20

.NET Framework 版の ASP.NET MVC アプリでは、Controller で作成されて View に渡された匿名型のオブジェクトには View 内部ではアクセスできず、 アクセスしようとすると以下の画像のように RuntimeBinderException がスローされるということを書きます。

RuntimeBinderException

理由は、Microsoft のドキュメント「匿名型」に書いてあるように、「匿名型のアクセシビリティ レベルは internal であるため」です。internal 型またはメンバは、同じアセンブリのファイル内でのみしかアクセスできません。

.NET Framework 版の MVC アプリでは、Controller など拡張子が cs のファイルは Visual Studio で単一アセンブリにコンパイルされ、bin フォルダに配置されます。

一方、View (.cshtml) は、デフォルトではランタイムコンパイルとなり、アプリをデプロイした後サーバーで動的にアセンブリにコンパイルされ、サーバーの Temporary ASP.NET Files フォルダに保存されます。

という訳で、Controller と View とは違うアセンブリになるため、Controller で作成された匿名クラスのプロパティは View では見えず、アクセスしようとすると上の画像のように RuntimeBinderException がスローされます。

ただし、ASP.NET Core アプリの場合は、Controller と View はデフォルトで単一アセンブリにコンパイルされるので、上に書いたような問題は起きません。(ASP.NET Core のコンパイルについて、詳しくは Microsoft のドキュメント「ASP.NET Core での Razor ファイルのコンパイル」を見てください)

上の画像を表示した MVC アプリの Controler と View のコードを以下に載せておきます。Visual Studio 2022 のテンプレートを使って作成した .NET Framework 4.8 の MVC5 アプリです。

Controller

using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5App2.Models;

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


        public async Task<ActionResult> Test()
        {
            var products = db.Products
                .Select(p => new
                {
                    Id = p.ProductID,
                    Name = p.ProductName,
                    Price = p.UnitPrice
                });

            ViewBag.List = await products.ToListAsync();

            return View();
        }
    }
}

View

@{
    ViewBag.Title = "Test";
}

<h2>Test</h2>

<br />
<table  class="table">
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Price</th>
    </tr>
    @foreach (var item in ViewBag.List)
    {
        <tr>
            <td>@item.Id</td>
            <td>@item.Name</td>
            <td>@item.Price</td>
        </tr>
    }
</table>

解決策は、匿名型を使うのは止めて、以下のようなカスタムクラスを定義し、

public class DTO
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal? Price { get; set; }
}

以下のように List<DTO> 型のデータを生成して View に渡すことです。

public async Task<ActionResult> Test()
{
    var products = db.Products
        .Select(p => new DTO  // List<DTO> を生成
        {
            Id = p.ProductID,
            Name = p.ProductName,
            Price = p.UnitPrice
        });

    ViewBag.List = await products.ToListAsync();

    return View();
}

なお、上にも書きましたように、ASP.NET Core アプリの場合は、Controller と View はデフォルトで同じアセンブリにコンパイルされるので、上に書いた問題は起きません。

なので、匿名型を使っても以下の画像の通り期待した結果が得られます。

ASP.NET Core での結果

Tags: , ,

MVC

FriendlyUrls を有効にしておくとページの静的メソッドが呼び出せない

by WebSurfer 27. September 2024 13:50

Visual Studio 2019 / 2022 のテンプレートを使って作成する Web Forms アプリのプロジェクトでは、Microsoft.AspNet.FriendlyUrls という NuGet パッケージがインストールされ、デフォルトで有効になるように設定されていますが、デフォルトの設定のままではクライアントスクリプトでページの静的メソッドが呼び出せないという話を書きます。

FriendlyUrls NuGet パッケージ

いろいろ説明すると長くなるのでまず最初に解決策を書いておきます。

自動生成されて App_Start フォルダに配置されている RoutConfig.cs で、以下のコードの通りリダイレクトモードが Permanent に設定されていますが、解決策はそれを Off に変更することです。

using Microsoft.AspNet.FriendlyUrls;
using System.Web.Routing;

namespace WebForms3
{
    public static class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            var settings = new FriendlyUrlSettings();

            // デフォルトは Permanent
            // settings.AutoRedirectMode = RedirectMode.Permanent;

            // Off に変更
            settings.AutoRedirectMode = RedirectMode.Off;

            routes.EnableFriendlyUrls(settings);
        }
    }
}

Off にするのは SEO 的に好ましくないかもしれません。しかし、FriendlyUrls を使って拡張子 .aspx なしの url で呼び出せるようにしたい、かつクライアントスクリプトでページの静的メソッドを呼び出せるようにしたいのであれば、他に方法はなさそうです。


以下にどういうメカニズムになっているかなどの説明を書いておきます。興味があれば読んでください。

FriendlyUrls とは何か

ASP.NET Web Forms アプリの場合、ブラウザからページを呼び出す際、url に https://.../default.aspx というように目的のページのファイル名を拡張子を付けて指定する必要があります。

FriendlyUrls を利用すると、一番最初にブラウザから拡張子 .aspx 付きで要求した場合は、デフォルトのリダイレクトモードの設定 Permanent では 301 Moved Permanently 応答が返ってきて、拡張子 .aspx 無しの url にリダイレクトされます。

下の Fiddler の画像の #9 を見てください。青枠で示したように Default.aspx を要求すると、赤枠で示したように 301 Moved Permanently 応答が返ってきて Default にリダイレクト指示が出ています。

FriendlyUrls NuGet パッケージ

ブラウザはリダイレクト指示を受けて Default を要求します。上の Fiddler の画像の #14 がそれです。Default を要求されたサーバー側では、FriendlyUrls がそれを Default.aspx にルーティングしてくれる���で、Default.aspx が応答として返されます。

リダイレクトの際の HTTP 応答は 301 Moved Permanently なので、ブラウザは Default.aspx ⇒ Default に恒久的に移ったという情報をキャッシュします。なので2 回目以降は、たとえブラウザのアドレスバーに Default.aspx と入力して要求をかけても、ブラウザからは Default という url で要求が出ます。

ページの静的メソッド

ページの静的メソッドとは、ASP.NET Web Forms のページ (.aspx.cs) に WebMethodAttribute 属性を付与して配置した public static メソッドで、AJAX を利用してクライアントスクリプトから呼び出すことができるものです。

jQuery ajax や fetch 等を使ったクライアントスクリプトで JSON 形式のデータを送信して JSON 形式の応答を受けるという Web API 的な使い方ができるので、ASP.NET Web Forms アプリでは利用価値は高いと思います。

詳しくは、先の記事「ASP.NET AJAX でページの静的メソッド呼び出し」を見てください。

FriendlyUrls のリダイレクトモード

リダイレクトモードは Parmanent, Temporary, Off のいずれかに設定でき、設定によって以下のように動きが異なります。

Permanent の場合、ブラウザから Default.aspx というように拡張子 .aspx 付きで要求を受けたときは 301 Moved Permanently 応答が返され、Default にリダイレクトされるようになっています (Temporary の場合は 302 Found 応答でリダイレクトされます)。

Off の場合はリダイレクトされません。Default.aspx で要求を受けるとそのまま Default.aspx が返されます。Default で要求を受けると、FriendlyUrls によって Default.aspx にルーティングされ、Default.aspx が返されます。

リダイレクトモードが Parmanent の時の動作

リダイレクトモードが Parmanent で、以下のように jQuery ajax を使って WebForm1.aspx ページにある静的メソッド MyWebMethod を呼び出したとします。

function CallWebMthod(productId) {
    $.ajax({
        type: "POST",
        url: "/WebForm1.aspx/MyWebMethod",
        contentType: "application/json; charset=utf-8",
        data: `{ "id": ${productId} }`
    }).done(response => {
        
        // ・・・中略・・・

    });
}

その応答を Fiddler で応答を見ると以下の通りとなっていました。これはリダイレクトモードが Temporary の場合も同じです。

Fiddler で見た応答

要求が WebForm1.aspx と拡張子 .aspx が付いているのでリダイレクト応答を返す動きになるはずですが、要求を受けてリダイレクト応答を返すまでのプロセスのどこかで 401 Unauthorized となり (赤枠部分)、認証プロセスに入ろうとしたが失敗したのでその旨応答を返した (青枠部分) ということのように見えます。プロセスのどこでどういう理由でそうなるかなど詳しいことは分かりません。

ちなみに、上の画像の青枠部分の JSON 文字列は、jQuery ajax のコードの変数 response に、JavaScript オブジェクトにデシリアライズした形で受け取ることができます。

リダイレクトモードが Off の時の動作

FriendlyUrls が有効なまま静的メソッド MyWebMethod を呼び出せるようにするには、上に書いたように RoutConfig.cs のコードのリダイレクトモードを Off に変更してやります。

そうすれば上の jQuery のコードの /WebForm1.aspx/MyWebMethod のように拡張子 .aspx が付いた url の要求を受けてもリダイレクト応答を返すためのプロセスが動くことはなく、即 WebForm1.aspx ページの静的メソッド MyWebMethod が呼ばれて期待通り MyWebMethod の応答が返ってきます。

注: url: "/WebForm1/MyWebMethod" とすると (.aspx 拡張子を削除すると)、リダイレクトモード Permanent, Temporary, Off いずれの設定でも WebForm1.aspx ページ本体が応答として返ってきます。それは FriendlyUrls により WebForm1 は WebForm1.aspx にルーティングされ、MyWebMethod はサーバーに渡すパラメータと解釈され、WebForm1.aspx ページ本体が返すべきリソースと判断されるからだと思います。

サンプルコード

この記事を書くにあたって検証用に作成した WebForm1.aspx.cs、WebForm1.aspx のコードを載せておきます。以下の画像が実行結果で、LinkButton クリックで jQuery ajax を使って静的メソッド MyWebMethod を呼び出し、その応答を GridView の下に表示しています。

WebMethod の呼び出し

WebForm1.aspx.cs

using System;
using System.Web.Services;

namespace WebForms3
{
    public partial class WebForm1 : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {

        }

        [WebMethod]
        public static string MyWebMethod(int id)
        {
            return $"WebMethod called with id={id}";
        }

        protected string SetOnClientClick(int id)
        {
            return $"CallWebMthod({id}); return false;";
        }
    }
}

WebForm1.aspx

<%@ Page Language="C#" AutoEventWireup="true"
    CodeBehind="WebForm1.aspx.cs"
    Inherits="WebForms3.WebForm1" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
    <script src="Scripts/jquery-3.7.0.js"></script>
    <script type="text/javascript">
        function CallWebMthod(productId) {
            $.ajax({
                type: "POST",
                url: "/WebForm1.aspx/MyWebMethod",
                contentType: "application/json; charset=utf-8",
                data: `{ "id": ${productId} }`
            }).done(response => {
                $("#result").empty;
                // .NET 3.5 で追加された d パラメータの処置。
                if (response.hasOwnProperty('d')) {
                    $("#result").text(response.d);
                }

                // リダイレクトモードが Parmanent の時、上の url
                // を要求するとサーバー内で 401 エラーとなって下
                // の応答が返ってくる:
                // {"Message":"認証に失敗しました。" ...}
                if (response.hasOwnProperty('Message')) {
                    $("#result").text(response.Message);
                }
            });
        }
    </script>
</head>
<body>
    <form id="form1" runat="server">
        <asp:SqlDataSource ID="SqlDataSource1"
            runat="server"
            ConnectionString="<%$ ConnectionStrings:NORTHWINDConnectionString %>"
            SelectCommand="SELECT TOP(10) [ProductID], [ProductName] FROM [Products]">
        </asp:SqlDataSource>

        <asp:GridView ID="GridView1"
            runat="server"
            AutoGenerateColumns="False"
            DataKeyNames="ProductID"
            DataSourceID="SqlDataSource1">
            <Columns>
                <asp:BoundField DataField="ProductID"
                    HeaderText="ProductID"
                    InsertVisible="False"
                    ReadOnly="True"
                    SortExpression="ProductID" />
                <asp:BoundField DataField="ProductName"
                    HeaderText="ProductName"
                    SortExpression="ProductName" />
                <asp:TemplateField HeaderText="Click to call WebMethod">
                    <ItemTemplate>
                        <asp:LinkButton ID="LinkButton1"
                            runat="server"
                            OnClientClick='<%# SetOnClientClick((int)Eval("ProductID")) %>'>
                    LinkButton
                        </asp:LinkButton>
                    </ItemTemplate>
                </asp:TemplateField>
            </Columns>
        </asp:GridView>

        <div id="result"></div>
    </form>
</body>
</html>

Tags: , , , ,

ASP.NET

JWT からクレーム情報を取得

by WebSurfer 20. August 2024 16:47

トークンベース (JWT) の認証を実装した ASP.NET Core Web API アプリで、クライアントから送信されてきた JWT から、ユーザー名やトークンの有効期限などの情報を取得する方法を書きます。

デコードされた JWT

実は最近知ったのですが、先の記事「ASP.NET Core Web API に Role ベースの承認を追加」に書いた JWT による認証を実装した ASP.NET Core Web API アプリでは、上の画像の JWT の Payload 部分は ASP.NET Core で言うクレームのコレクションとして取り扱われるようです。

具体的に言うと、JWT の Payload の項目から ClaimsIdentity オブジェクトが作られ、コントローラーに使われる ControllerBase クラスの User プロパティで取得できる ClaimsPrincipal オブジェクトに ClaimsIdentity が含まれるようになります。

なので、上の画像のように Payload に Role を追加すれば、先の記事に書いたように [Authorize(Roles = "Admin")] 属性によって Web API 側でアクセス制限ができるようになります。

さらに、ClaimsIdentity オブジェクトの各クレームの値は ClaimsPrincipal クラスの FindFirst(String) メソッドを使って取得できますので、必要があればサーバー側でユーザー名とかトークンの有効期限などを取得して、その内容に応じて何らかの処置を行うというようなことも可能になります。

以下に具体例を書いておきます。

まず、上の画像のように JWT の Payload に Role、UserId、exp を含める方法ですが、先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」で紹介したトークンを発行する API の BuildToken メソッドで、JwtSecurityToken コンストラクタの引数の claims に以下のようにロール情報と UserId を追加します。上の画像の JWT の Payload の "exp" は引数 expires に設定した UNIX 時間となります。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace WebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TokenController : ControllerBase
    {
        private readonly IConfiguration _config;
        private readonly UserManager<IdentityUser> _userManager;

        public TokenController(IConfiguration config,
                               UserManager<IdentityUser> userManager)
        {
            _config = config;
            _userManager = userManager;
        }

        [AllowAnonymous]
        [HttpPost]
        public async Task<IActionResult> CreateToken(LoginModel login)
        {
            string? id = login.Username;
            string? pw = login.Password;
            IActionResult response = Unauthorized();

            if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(pw))
            {
                var user = await _userManager.FindByNameAsync(id);
                if (user != null && 
                    await _userManager.CheckPasswordAsync(user, pw))
                {
                    // クライアントから送信されてきた id を UserId 
                    // として JWT の Payload に含める
                    var tokenString = BuildToken(id);

                    response = Ok(new { token = tokenString });
                }
            }

            return response;
        }

        private string BuildToken(string userId)
        {
            var key = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

            var creds = new SigningCredentials(
                key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: _config["Jwt:Issuer"],
                audience: _config["Jwt:Issuer"],

                // Role 情報と UserID を追加
                claims: [ 
                    new Claim(ClaimTypes.Role, "Admin"),
                    new Claim("UserId", userId)
                ],

                notBefore: null,
                expires: DateTime.Now.AddMinutes(30),
                signingCredentials: creds);

            return new JwtSecurityTokenHandler().WriteToken(token);
        }
    }

    public class LoginModel
    {
        public string? Username { get; set; }
        public string? Password { get; set; }
    }
}

Web API のコントローラーで、ControllerBase.User プロパティから取得できる ClaimsPrincipal オブジェクトの FindFirst(String) メソッドを使って、JWT の Payload の情報を取得できます。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

namespace WebApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        // ・・・中略・・・

        [Authorize(Roles = "Admin")]
        [HttpGet(Name = "GetWeatherForecast")]
        public IEnumerable<WeatherForecast> Get()
        {
            // 2024/8/20
            // JWT の Payload の情報は以下のようにして取得できる
            string? role = User.FindFirst(ClaimTypes.Role)?.Value;
            string? userId = User.FindFirst("UserId")?.Value;
            string? exp = User.FindFirst("exp")?.Value;  // UNIX 時間
            if (exp != null)
            {
                var ticks = long.Parse(exp) * 1000L * 1000L * 10L + 
                            DateTime.UnixEpoch.Ticks;
                var expDateTime = new DateTime(ticks, DateTimeKind.Utc);
                var dateTimeUtcNow = DateTime.UtcNow;
                bool isBeforeExp = expDateTime > dateTimeUtcNow;
            }

            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}

クライアントから上の画像の JWT を送信した結果は下の画像の通りとなります。Web API のコントローラーで上の画像の Payload の情報を取得できていることが分かりますでしょうか?

JWT の Payload 情報の取得結果



【オマケ】

本題とは直接関係ないことですが、Microsoft のドキュメント「ASP.NET Core でのクレーム ベースの承認」に ClaimsIdentity というのは何かを、運転免許証を例にとって説明してあって分かりやすかったので、忘れないように以下に抜粋を貼っておきます。

"運転免許証にはあなたの生年月日が記載されています。 この場合、クレーム名は DateOfBirth になり、クレームの値は、たとえば 8th June 1970 となります。そして発行者は、運転免許証機関になります。クレーム ベースの承認では、簡単に言うと、クレームの値がチェックされ、その値に基づいてリソースへのアクセスが許可されます。たとえば、ナイト クラブへの入場 (アクセス) であれば、承認プロセスは次のようになる可能性があります。"

"出入口のセキュリティ責任者が、あなたの生年月日クレームの値と、発行者 (運転免許機関) を信頼するかどうかを評価します。"

ちなみに、ClaimsIdentity クラスの解説は以下のようになっています。

"ClaimsIdentity クラスは、クレームベースの ID の具体的な実装です。つまり、クレームのコレクションによって記述される ID です。クレームは発行者によるエンティティに関する宣言で、プロパティ、権利、またはその他の品質が記述されます。このようなエンティティは、クレームの対象と言われます。クレームは Claim クラスによって表されます。ClaimsIdentity に含まれるクレームは、対応する ID が表すエンティティに記述し、承認と認証の決定を行うために使用できます。"

免許証の例を読んでからでないと分かりにくいかも。何を隠そう、自分はさっぱり分かりませんでした。(笑)

Tags: , , ,

Authentication

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  October 2024  >>
MoTuWeThFrSaSu
30123456
78910111213
14151617181920
21222324252627
28293031123
45678910

View posts in large calendar