ASP.NET Core Razor Pages の勉強備忘録 モデルの検証について

Razor Pagesの様々な機能を学習しています。
本日は復習も兼ねてモデルの検証について調べました。
モデル検証はASP.NET MVCの時と比べてそれほど大きくは変わっていないという印象を受けました。。

前回: mrgchr.hatenablog.com

環境

検証属性付きModel(ViewModel)

ASP.NET Coreでも、下記のようにモデル(ViewModel)に各種DataAnnotationに関する属性を付与することで、ユーザー入力を検証することができます。
このあたりはMVCの時からそれほど大きく変化してはいないと思っています。

// Models/RegisterUserVM.cs
public class RegisterUserVM
{
  [Required]
  [EmailAddress]
  public string Email { get; set; }

  [Required]
  [StringLength(64, MinimumLength = 8)]
  [DataType(DataType.Password)]
  public string Password { get; set; }

  [Compare(nameof(Password))]
  [DataType(DataType.Password)]
  public string ReEnterPassowrd { get; set; }
}

docs.microsoft.com

これらDataAnnotation属性のソースはGitHub上に公開されています。 github.com

サーバーサイドでのモデル検証

まずは、サーバーサイドでのモデル検証機能を確認します。
後述しますが、クライアントサイド(js)でも各種検証をすることが可能です。
ですが、クライアントサイドはユーザーの利便性のためにするもので、セキュリティ面ではほとんど意味がありません。
そのため、優先度としてはサーバーサイドでの検証機能の確保が先になります。

<!-- Pages/RegisterUserSample.cshtml -->
@page 
@model WebApplication2.Pages.RegisterUserSampleModel
@{
  ViewData["Title"] = "Register User";
}
<h2>@ViewData["Title"].</h2>
<h3>@Model.Message</h3>

<form method="post">
  <div asp-validation-summary="All"></div>
  @Html.EditorFor(m => m.RegisterUserVM)
  <input type="submit" value="Submit" />
</form>
[AutoValidateAntiforgeryToken]
public class RegisterUserSampleModel : PageModel
{
  public string Message { get; set; }

  [BindProperty]
  public RegisterUserVM RegisterUserVM { get; set; }

  public void OnGet()
  {
    Message = "Register User";
  }

  public void OnPost()
  {
    if (!ModelState.IsValid)
    {
      Message = "Error";
    }
    else
    {
      Message = @$"Id: {RegisterUserVM.Email} is Created.";
    }
  }
}

このページのFormがSubmitされると、PageModelのOnPost()が呼び出されその時点でモデルバインドとモデル検証が自動的に行われます。
ModelState.IsValidにてモデルの状態(=検証結果)を確認できます。

モデルの状態では、モデル バインドとモデル検証の 2 つのサブシステムで発生したエラーが表されます。 モデル バインドで発生するエラーは、一般に、データ変換エラーです (たとえば、整数が必要なフィールドに "x" が入力された場合)。 モデル検証は、モデル バインドの後で行われて、データがビジネス ルールに従っていないエラーが報告されます (たとえば、1 から 5 までのレーティングが必要なフィールドに 0 が入力された場合)。

モデル バインドとモデル検証はどちらも、コントローラー アクションまたは Razor Pages ハンドラー メソッドの実行前に行われます。 Web アプリでは、ModelState.IsValid を調べて適切に対処するのはアプリの責任です。 通常、Web アプリではエラー メッセージを含むページを再表示します。

MVC時代と比べて変化している個所は、[BindProperty]の利用と検証のサマリー表示での<div asp-validation-summary="All"></div>でしょうか。
MVC時代ではたいていViewModelとして入力フォームモデルをビューにバインドしていたため、<div asp-validation-summary="ModelOnly"></div>とすることが多かったですが、 今回は[BindProperty]のプロパティを検証するために、<div asp-validation-summary="All"></div>とする必要がありました。
余談ではありますが、@Html.EditorFor(m => m.RegisterUserVM)の表記が使えるとこういう時は楽で良いですね。なんだかんだでMVC時代の手法も便利です。

検証機能を確認すると次のようになりました。

f:id:mrgchr:20190928101828p:plain

(サーバーサイドでの)モデル検証にて不合格となり、Error表記となっていると確認できました。

クライアントサイドでのモデル検証

サーバーサイドでのモデル検証は非常に大事なのですが、問題点として「ユーザーがPostするまでミスなどに気づけない」という点が挙げられます。
クライアントサイド検証ではユーザーが実際にPostする前(Submitボタンを押した後)にjavascriptにて検証を行い、検証結果が不合格であればエラーを表示しPostを行わないというものです。

Visual StudioからASP.NET CoreのWebアプリを作成した場合は、下記のファイルが存在していると思います。

<!-- Pages/Shared/_ValidationScriptsPartial.cshtml -->
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

これをPageファイルに追記することでクライアントサイドモデル検証を有効にできます。

<!-- Pages/RegisterUserSample.cshtml -->
...
<form method="post">
  <div asp-validation-summary="All"></div>
  @Html.EditorFor(m => m.RegisterUserVM)
  <input type="submit" value="Submit" />
</form>

@section Scripts {
  @{await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

もちろん、モデル情報からサーバーサイド・クライアントサイド両方の検証機能が自動で追加されるのは素晴らしいのですが、私はサーバーサイドでのモデル検証とクライアントサイドのモデル検証は全然役割が別のものであると考えています。
クライアントサイドの検証は、やろうと思えばユーザー側で無効化できるため、セキュリティ面ではほとんど意味がありません。各種問題のあるデータのリジェクトはサーバーサイドでのモデル検証の責任です。
クライアントサイドの検証を開発段階で常にONにしておくとサーバーサイド検証の漏れやミスに気づきにくくなるのでご注意ください。 ところで、この_ValidationScriptsPartial.cshtmlですが、以前のバージョン(Visual Studio? .NET Core2.x?)ではenvironmentによってCDN利用(+ fallback)かどうかを選ぶようになっていたと思うのですが、随分とシンプルになりましたね。
ちなみにCDNは下記のようです。

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.17.0/jquery.validate.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"></script>

各種表記を変更する

上記まではデフォルトの表記(見た目)を使ってきましたが、Modelに[DisplayAttribute]属性やDataAnotationに各種情報を与えることで表記を変更できます。

public class RegisterUserVM
{
  [Required(ErrorMessage = "{0}: 必須項目です")] //{0}はプロパティのDisplayNameが入るプレイスホルダー
  [EmailAddress]
  [Display(Name = "メールアドレス")]
  public string Email { get; set; }

  [Required(ErrorMessage = "{0}: 必須項目です")]
  [StringLength(64, MinimumLength = 8, ErrorMessage = "{0}: {2}文字以上 {1}文字以下の文字列を入力してください")] //{1}はMaximumLength, {2}はMinimumLength が入るプレイスホルダー
  [DataType(DataType.Password)]
  [Display(Name = "パスワード")]
  public string Password { get; set; }

  [Compare(nameof(Password), ErrorMessage = "'{0}'と'{1}'が一致しません")] //{1}はプロパティのDisplayNameが入るプレイスホルダー
  [DataType(DataType.Password)]
  [Display(Name = "パスワード(確認用)")]
  public string ReEnterPassowrd { get; set; }
}

次のようになります。 f:id:mrgchr:20190928104010p:plain

言語化とかを考える

例えばRequiredAttributeThe {0} field is required.({0}はフィールドのDisplayNameが入るプレイスホルダー)という文言も、私の理解では各種言語の公式翻訳は現時点では提供されていないはずです*1

  <data name="RequiredAttribute_ValidationError" xml:space="preserve">
    <value>The {0} field is required.</value>
  </data>

referencesource/DataAnnotationsResources.resx at master · microsoft/referencesource · GitHub

ソース(resx)は公開されているので、必要に応じて自分で各種言語に翻訳し利用することになるようです。MVCの時にもこういうことを調べた記憶があります。

resxを用意する

私はresourceはresource用のDLLを用意するのが個人的な好みなので、新しいプロジェクトを作成します。
(新しいプロジェクトとして独立させなければならない、という理由はありません。好みです) f:id:mrgchr:20190928105555p:plain

Visual Studio上で[Add]->[New Item...]より.resxファイルを作成します。
手元のVisual Studio 2019 16.4.0 Preview 1.0では.resxファイルのエントリがなかったので、テキストファイルの拡張子を無理無理.resxに置き換えました。ちゃんと動作しました。

次のようにAccess Modifier: PublicとしてViewModel用のリソースRegisterUserVM.resxと検証属性のエラー表示用のDataAnnotations.resxを作りました。 (BuildAction: Embedded resourceCustom Tool: PublicResXFileCodeGenerator)

f:id:mrgchr:20190928105807p:plain

f:id:mrgchr:20190928110100p:plain

(DLLにしたので)WebApp側で参照を解決し、ViewModelでこれらのリソースを使うには下記のようにします。

using DARes = WebApp.Res.Resources.DataAnnotations;
using ModelRes = WebApp.Res.Resources.RegisterUserVM;

public class RegisterUserVM
{
  [Required(ErrorMessageResourceName = nameof(DARes.RequiredAttribute_ValidationError)
    , ErrorMessageResourceType = typeof(DARes))]
  [EmailAddress(ErrorMessageResourceName = nameof(DARes.EmailAddressAttribute_Invalid)
    , ErrorMessageResourceType = typeof(DARes))]
  [Display(Name = nameof(ModelRes.Email), ResourceType = typeof(ModelRes))]
  public string Email { get; set; }

  [Required(ErrorMessageResourceName = nameof(DARes.RequiredAttribute_ValidationError)
    , ErrorMessageResourceType = typeof(DARes))]
  [StringLength(64, MinimumLength = 8
    , ErrorMessageResourceName = nameof(DARes.StringLengthAttribute_ValidationErrorIncludingMinimum)
    , ErrorMessageResourceType = typeof(DARes))]
  [DataType(DataType.Password)]
  [Display(Name = nameof(ModelRes.Password), ResourceType = typeof(ModelRes))]
  public string Password { get; set; }

  [Compare(nameof(Password), ErrorMessageResourceName = nameof(DARes.CompareAttribute_MustMatch)
    , ErrorMessageResourceType = typeof(DARes))]
  [DataType(DataType.Password)]
  [Display(Name = nameof(ModelRes.ReEnterPassword), ResourceType = typeof(ModelRes))]
  public string ReEnterPassowrd { get; set; }
}

うおお長い。まず、ErrorMessageResourceNameErrorMessageResourceTypeの名前がすでに長い。
もちろん思う通り動作しましたが、この長さには怯みます。

mrgchr.hatenablog.com

*1:されていたら教えてください