WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

カスタムモデルバインダ (Core 3.1)

by WebSurfer 15. February 2020 14:34

ASP.NET Core 3.1 MVC アプリケーションでカスタムモデルバインダを利用するコードを備忘録として書いておきます。(注:.NET Framework の MVC ではありません)

カスタムモデルバインダ (Core 3.1)

先の記事「カスタムモデルバインダ (MVC5)」で .NET Framework の MVC5 のカスタムモデルバインダのコードを書きましたが、それと同じ機能を ASP.NET Core 3.1 MVC で実装してみます。

モデルバインド機能だけでなく、先の MVC5 版の記事と同様に、ユーザー入力の検証とエラーメッセージの表示ができるようにしました。

カスタムモデルバインダが継承するインターフェイスは Microsoft.AspNetCore.Mvc.ModelBinding 名前空間に属する IModelBinder Interface となります。MVC5 用と名前は同じですが中身が異なることに注意してください。

実装するのは BindModelAsync(ModelBindingContext) という Task を返す非同期メソッドになります。

加えて、ヘルパーメソッドで使っている GetValue(key) メソッドが返す ValueProviderResult は MVC5 と Core で名前は同じながら別物で、Core 用は ValueProviderResult 構造体となります。ユーザーから POST されてきた値は FirstValue プロパティを使って文字列として取得します。

Model, カスタムモデルバインダ、Controller のサンプルコードを以下にアップしておきます。上の画像を表示したものです。View のコードはスキャフォールディング機能を使って自動生成できるので割愛します。

モデルとカスタムモデルバインダ

モデルのコードは MVC5 用と全く同じです。カスタムモデルバインダのコードは上に述べた点が異なりますが、他は MVC5 用と同じです。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Globalization;

namespace MvcCoreApp.Models
{
  // モデル(MVC5 用と同じ)
  public class Person2
  {
    public int PersonId { set; get; }

    [Display(Name = "名前")]
    public string Name { set; get; }

    [Display(Name = "メールアドレス")]
    public string Mail { set; get; }

    // int? 型にしないと未入力に対応できない
    [Display(Name = "年齢")]
    public int? Age { set; get; }
  }

  // カスタムモデルバインダ
  public class CustomModelBinder : IModelBinder
  {
    public Task BindModelAsync(ModelBindingContext context)
    {
      if (context == null)
      {
        throw new ArgumentNullException("context");
      }

      var model = new Person2();
      model.Name = PostedData(context, "Name");
      model.Mail = PostedData(context, "Mail");
      string age = PostedData(context, "Age");

      if (string.IsNullOrEmpty(age))
      {
        context.ModelState.AddModelError("Age", 
                                         "年齢は必須");
      }
      else
      {
        int intAge = 0;
        if (!int.TryParse(age, out intAge))
        {
          context.ModelState.AddModelError("Age", 
                                         "年齢は整数");
        }
        else
        {
          model.Age = intAge;
          if (intAge < 0 || intAge > 200)
          {
            context.ModelState.AddModelError("Age", 
                           "年齢は 0 ~ 200 の範囲");
          }
        }
      }

      if (string.IsNullOrEmpty(model.Name))
      {
        context.ModelState.AddModelError("Name", 
                                         "名前は必須");
      }
      else if (model.Name.Length < 2 || 
               model.Name.Length > 20)
      {
        context.ModelState.AddModelError("Name", 
                            "名前は 2 ~ 20 文字の範囲");
      }
      else if (model.Name.StartsWith("佐藤") && 
               model.Age < 20)
      {
        context.ModelState.AddModelError("", 
             "佐藤さんは二十歳以上でなければなりません");
      }

      if (string.IsNullOrEmpty(model.Mail))
      {
        context.ModelState.AddModelError("Mail", 
                                 "メールアドレスは必須");
      }
      else
      {
        bool isValidEmai = Regex.IsMatch(model.Mail,
            @"・・・正規表現(省略)・・・",
            RegexOptions.IgnoreCase, 
            TimeSpan.FromMilliseconds(250));

        if (!isValidEmai)
        {
          context.ModelState.AddModelError("Mail", 
                    "有効な Email 形式ではありません");
        }
      }

      context.Result = ModelBindingResult.Success(model);
      return Task.CompletedTask;
    }

    // ヘルパーメソッド
    // GetValue(key) メソッドが返す ValueProviderResult は
    // MVC5 と Core では別物。前者はクラスで後者は構造体。
    // 値を取得するには FirstValue プロパティを使う
    private static string PostedData(
        ModelBindingContext context, string key)
    {
      var result = context.ValueProvider.GetValue(key);
      context.ModelState.SetModelValue(key, result);
      return result.FirstValue;
    }
  }
}

Controller / Action Method

MVC5 の場合と同様に、モデルバインダをターゲットとなる型に関連付けるため、POST データを受けるアクションメソッドの引数に [ModelBinder(typeof(CustomModelBinder))] を付与します。

using System;
using Microsoft.AspNetCore.Mvc;
using MvcCoreApp.Models;

namespace MvcCoreApp.Controllers
{
  public class ValidationController : Controller
  {
    public IActionResult Create4()
    {
      return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult Create4(
      [ModelBinder(typeof(CustomModelBinder))] Person2 model)
    {
      if (!ModelState.IsValid)
      {
        return View(model);
      }

      return RedirectToAction("Index", "Home");
    }
  }
}

Tags: , , ,

CORE

カスタムモデルバインダ (MVC5)

by WebSurfer 14. February 2020 15:29

.NET Framework の ASP.NET MVC5 アプリケーションでカスタムモデルバインダを利用するコードを備忘録として書いておきます。(注:下のサンプルコードは Core には使えません)

カスタムモデルバインダ

モデルバインド機能だけでなく、ユーザー入力の検証とエラーメッセージの表示ができるようにしてみました。

上の画像を表示する Model と Controller のサンプルコードを以下にアップしておきます。View のコードはスキャフォールディング機能を使って自動生成できるので省略します。

Model とカスタムモデルバインダ

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;
using System.Globalization;

namespace Mvc5App.Models
{
  // Model
  public class Person2
  {
    public int PersonId { set; get; }

    [Display(Name = "名前")]
    public string Name { set; get; }

    [Display(Name = "メールアドレス")]
    public string Mail { set; get; }

    // Age は int? にしないと未入力ではカスタムモデルバイ
    // ンダでも動かない。既定のモデルバインダと同様に null
    // が渡されて例外がスローされるようで「年齢 フィールド
    // が必要です。 」というエラーメッセージが表示される
    [Display(Name = "年齢")]
    public int? Age { set; get; }
  }

  // カスタムモデルバインダー
  public class CustomModelBinder : IModelBinder
  {
    public object BindModel(ControllerContext contContext, 
                          ModelBindingContext bindContext)
    {
      if (bindContext == null)
      {
        throw new ArgumentNullException("引数が null");
      }

      var model = new Person2();
      model.Name = PostedData<string>(bindContext, "Name");
      model.Mail = PostedData<string>(bindContext, "Mail");
      // ここでは string 型で取得する
      string age = PostedData<string>(bindContext, "Age");

      if (string.IsNullOrEmpty(age))
      {
        bindContext.ModelState.AddModelError("Age", 
                                           "年齢は必須");
      }
      else
      {
        int intAge;
        if (!int.TryParse(age, out intAge))
        {
          bindContext.ModelState.AddModelError("Age",
                                            "年齢は整数");
        }
        else
        {
          model.Age = intAge;
          if (intAge < 0 || intAge > 200)
          {
            bindContext.ModelState.AddModelError("Age", 
                                "年齢は 0 ~ 200 の範囲");
          }
        }
      }

      if (string.IsNullOrEmpty(model.Name))
      {
        bindContext.ModelState.AddModelError("Name",
                                            "名前は必須");
      }
      else if (model.Name.Length < 2 || 
               model.Name.Length > 20)
      {
        bindContext.ModelState.AddModelError("Name",
                             "名前は 2 ~ 20 文字の範囲");
      }

      else if (model.Name.StartsWith("佐藤") &&
               model.Age < 20)
      {
        bindContext.ModelState.AddModelError("",
              "佐藤さんは二十歳以上でなければなりません");
      }

      if (string.IsNullOrEmpty(model.Mail))
      {
        bindContext.ModelState.AddModelError("Mail",
                                  "メールアドレスは必須");
      }
      else
      {
        bool isValidEmai = Regex.IsMatch(model.Mail,
          @"・・・正規表現(省略)・・・",
          RegexOptions.IgnoreCase, 
          TimeSpan.FromMilliseconds(250));

        if (!isValidEmai)
        {
          bindContext.ModelState.AddModelError("Mail", 
                        "有効な Email 形式ではありません");
        }
      }

      return model;
    }

    // ヘルパーメソッド
    // Core では ValueProviderResult.ConvertTo メソッドは使え
    // ませんので注意。
    private static T PostedData<T>(ModelBindingContext context,
                                   string key)
    {
      var result = context.ValueProvider.GetValue(key);
      context.ModelState.SetModelValue(key, result);
      return (T)result.ConvertTo(typeof(T));
    }
  }
}

Controller / Action Method

モデルバインダをターゲットとなる型に関連付けるため、POST データを受けるアクションメソッドの引数に [ModelBinder(typeof(CustomModelBinder))] を付与します。(これはローカルな関連付けで、Global.asax の Application_Start メソッドでグローバルに関連付けを行うこともできるそうです)

using System;
using System.Web.Mvc;
using Mvc5App.Models;

namespace Mvc5App.Controllers
{
  public class ValidationController : Controller
  {
    // カスタムモデルバインダーを使ったサンプル
    public ActionResult Create4()
    {
      return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Create4(
      [ModelBinder(typeof(CustomModelBinder))] Person2 model)
    {
      if (!ModelState.IsValid)
      {
        return View(model);
      }

      return RedirectToAction("Index", "Home");
    }
  }
}

Tags: , ,

MVC

ASP.NET Core Web API と JWT

by WebSurfer 11. February 2020 18:48

ASP.NET Core 3.1 Web API でトークンベース認証を実装してアクセス制限し、ユーザー認証に ASP.NET Core Identity のユーザー情報を利用する方法を書きます。

結果の表示

.NET Framework Web API の場合は、先の記事「ASP.NET Web API の認証」で書きましたように、Visua Studio のテンプレートを使って認証を「個別のユーザーアカウントカウント」として自動生成すればデフォルトでトークンベースの認証が実装され、認証のためのユーザー情報のストアには ASP.NET Identity が使用されます。

Core Web API では自力での実装が必要になります。テンプレートで認証に「個別のユーザーアカウントカウント」を選択すると「クラウドの既存のユーザーストアに接続する」しか選べません。なので、認証なしの状態から Core 2 からサポートされたという JSON Web Token (JWT) を使った認証を実装することにします。

基本的な方法は Auth0 というサイトのブログの記事「ASP.NET Core 2.0 アプリケーションを JWT でセキュアする」(以下「Auth0 の記事」と書きます)に書いてあるのでそれを見れば済む話なのですが、リンク切れになったりすると困るので要点およびその記事には書いてないことを備忘録として残しておきます。

(1) プロジェクトの作成

元になる ASP.NET Core 3.1 Web API アプリのプロジェクトは Visual Studio Community 2019 のテンプレートで自動生成されたものを使います。以下の画像を見てください。認証は「なし」にしておきます。

プロジェクトの作成

テンプレートで自動生成したプロジェクトにはサンプルのコントローラ WeatherForecastController が実装されていて、Visual Studio からプロジェクトを実行([デバッグ(D)]⇒[デバッグなしで開始(H)])すると JSON 文字列が返ってきます。

そのアクションメソッド Get() に JWT ベースの認証を実装します(即ち、トークンが無いとアクセス拒否するようにします)。

(2) NuGet パッケージのインストール

下の画像の赤枠で囲んだ Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。青枠で囲んだものは、下に述べるユーザー認証に ASP.NET Core Identity のユーザー情報を利用する場合に必要になります。

NuGet パッケージのインストール

(3) JWT 認証スキーマを登録

自動生成された Startup.cs のコードの ConfigureServices メソッドで、AddAuthentication メソッドを使って JWT 認証スキーマを登録します。コードは Auth0 の記事のものをそのままコピペすれば OK です。using 句の追加を忘れないようにしてください。

さらに、認証を有効にするため Configure メソッドに app.UseAuthentication(); を追加します。既存のコードの app.UseAuthorization(); の前にする必要があるので注意してください。

具体的には以下のコードで「JWT ベースの認証を行うため追加」とコメントしたコードを追加します。

// ・・・前略・・・

// JWT ベースの認証を行うため追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

//・・・中略・・・

  public void ConfigureServices(IServiceCollection services)
  {
    // JWT ベースの認証を行うため追加
    services.AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
          options.TokenValidationParameters = 
            new TokenValidationParameters
            {
              ValidateIssuer = true,
              ValidateAudience = true,
              ValidateLifetime = true,
              ValidateIssuerSigningKey = true,
              ValidIssuer = Configuration["Jwt:Issuer"],
              ValidAudience = Configuration["Jwt:Issuer"],
              IssuerSigningKey = new SymmetricSecurityKey(
                  Encoding.UTF8.GetBytes(
                      Configuration["Jwt:Key"]))
            };
        });

    services.AddControllers();
  }

  public void Configure(IApplicationBuilder app, 
            IWebHostEnvironment env)
  {
    // ・・・中略・・・

    // JWT ベースの認証を行うため追加
    app.UseAuthentication();

    app.UseAuthorization();

    //・・・後略・・・

(4) Key と Issuer を appsettings.json に登録

上の (3) コードでは Key と Issuer を appsettings.json ファイルより取得するようにしていますので、以下のように "Jwt" 要素を追加します。

{

  ・・・中略・・・

  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKey",
    "Issuer": "https://localhost:44330"
  }
}

"AllowedHosts": "*" の後にカンマ , を追加するのを忘れないようにしてください。Key はパスワードのようなもので任意の文字列を設定できます(16 文字以上にしないとエラーになるようです)。Issuer はサービスを行う URL にします。

(5) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。using Microsoft.AspNetCore.Authorization; の追加を忘れないようにしてください。

ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずです。

(6) トークンを取得する API を実装

ユーザーの ID とパスワードを送信してトークンを取得する API を実装します。基本的には Auth0 の記事のコントローラ TokenController の通りですが、それを拡張してユーザー情報を既存の ASP.NET Core Identity のデータベースから取得して認証を行うようにしてみました。

コントローラ TokenController のコードは以下の通りです。UserManager<IdentityUser> オブジェクトへの参照を DI によって取得し、それを使って既存の ASP.NET Core Identity から情報を取得してユーザー認証に用いています。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
// 以下を追加
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.AspNetCore.Identity;

namespace WebApiJwtIdentity.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();
      var user = await _userManager.FindByNameAsync(id);
      if (user != null && 
          await _userManager.CheckPasswordAsync(user, pw))
      {
        var tokenString = BuildToken();
        response = Ok(new { token = tokenString });
      }

      return response;
    }

    private string BuildToken()
    {
      var key = new SymmetricSecurityKey(
          Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
      var creds = new SigningCredentials(
          key, SecurityAlgorithms.HmacSha256);

      var token = new JwtSecurityToken(
        _config["Jwt:Issuer"],
        _config["Jwt:Issuer"],
        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; }
  }
}

上記のコード以外にも以下の追加が必要です。

(a) 上の (2) の画像で青枠で囲んだ NuGet パッケージのインストール。

(b) IdentityDbContext を継承した ApplicationDbContext クラスを追加。Data フォルダを作ってそれにクラスファイルとして実装します。

using System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace WebApiJwtIdentity.Data
{
    public class ApplicationDbContext : IdentityDbContext
    {
        public ApplicationDbContext(
            DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

(c) appsettings.json に ASP.NET Core Identity が使う既存の SQL Server DB への接続文字列を追加。

(d) Startup.cs に以下を追加。

// 追加
using Microsoft.AspNetCore.Identity;
using WebApiJwtIdentity.Data;
using Microsoft.EntityFrameworkCore;

public void ConfigureServices(IServiceCollection services)
{
  // 追加
  services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
      Configuration.GetConnectionString(
        "MvcCoreIdentityContextConnection")));

  // 追加
  services.AddDefaultIdentity<IdentityUser>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

(7) 検証用 Home/Index ページを追加

以下は必須ではないですが、検証用の Home/Index ページを追加し、そこから jQuery ajax を使ってトークンの取得と認証が期待通りとなるかを確認してみます。

View のコードは以下のようになります。下のコードの Username と Password には "***" ではなくて有効な文字列を設定してください。このページを使って確認した結果が一番上の画像です。

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
  <meta name="viewport" content="width=device-width" />
  <title>Index</title>
  <script src="~/Scripts/jquery.js"></script>
  <script type="text/javascript">
  //<![CDATA[
    var tokenKey = 'accessToken';

    function getToken() {            
      var obj = { Username : "***", Password : "***" };
      var jsonString = JSON.stringify(obj);
      $.ajax({
        type: "POST",
        url: "/api/token",
        data: jsonString,
        contentType: "application/json; charset=utf-8",
        success: function (data, textStatus, jqXHR) {
          sessionStorage.setItem(tokenKey, data.token);
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

    function weatherForecast() {
      var token = sessionStorage.getItem(tokenKey);
      var headers = {};
      if (token) {
        headers.Authorization = 'Bearer ' + token;
      }

      $.ajax({
        type: "GET",
        url: "/WeatherForecast",
        headers: headers,
        cache: false,
        success: function (data, textStatus, jqXHR) {
          $('#output').empty();
          $.each(data, function (key, val) {
            var day = new Date(val.date);
            var dateString = day.getFullYear() + "年" +
                (day.getMonth() + 1) + "月" +
                day.getDate() + "日";
            $('#output').append(
              '<p>' + dateString + ' / ' +
              val.temperatureC + ' / ' +
              val.temperatureF + ' / ' +
              val.summary + '</p >');
          });                    
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

  //]]>
  </script>
</head >
<body>
    <h3>Web API から jQuery ajax を使ってデータの取得</h3>

    <input type="button" id="Button1" 
      value="WeatherForecast" onclick="weatherForecast();" />
    <input type="button" id="Button2" 
      value="Get Token" onclick="getToken();" />

    <hr />
    <p>結果の表示:</p>
    <div id="output"></div>
</body>
</html >

なお、元々のプロジェクトの設定が Web API 用ですので、そのままでは MVC 用の Controller と View は動きませんので注意してください。以下の設定が必要になります。

(a) Startup.cs で MVC 用のサービスの追加、静的ファイルの利用を可能にすること、ルーティングのためのマップ設定。

(b) launchSettings.json で "launchUrl" の "weatherforecast" を "home/index" に変更。

(c) jQuery を利用するので wwwroot/Script/jQuery.js を追加。

Tags: , , ,

CORE

About this blog

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

Calendar

<<  February 2020  >>
MoTuWeThFrSaSu
272829303112
3456789
10111213141516
17181920212223
2425262728291
2345678

View posts in large calendar