WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

異なるフォルダのファイルをアップロード

by WebSurfer 2020年7月9日 21:33

ブラウザを使って自分の PC の複数のフォルダから複数のファイルを一度にアップロードするにはどうしたらよいかという話を書きます。

複数ファイルをアップロード

html の input type="file" 要素を使うことが前提です。ブラウザがサポートしていれば input type="file" 要素に multiple="multiple" を追加すれば、ユーザーは複数のファイルを選択して一度にアップロードすることができます。

ファイルを選択する際、上の画像の[ファイル選択]ボタン(画像は Chrome の例です。ブラウザによって異なります)をクリックすると下の画像のダイアログが表示されますので、Shift キーや Ctrl キーを使って複数のファイルを選択できます。

送信するファイルの選択

その後[開く(O)]ボタンをクリックすると送信準備完了となり、form を method="post" で submit してやればサーバーに選択された複数のファイルが送信されます。

ただし、上の操作で送信するファイルを選択できるのは一回だけです。この後、もう一度同じ操作を行って別のファイルを追加することはできません。それをすると、前の操作で選択したファイルは送信対象には含まれなくなり、後の操作で選択したファイルのみが送信されるようになります。それは同じフォルダで繰り返し行う場合も異なるフォルダに移動して行う場合も同じです。

ということは、フォルダが異なるファイルを選択するには各フォルダに移動して選択操作を行わざるを得ませんが、送信できるのは最後の操作で選択したファイルのみになります。なので、複数のフォルダにある複数のファイルを一度にアップロードするのは普通のやり方(form を submit する方法)ではできないようです。

代案は HTML5 File APIFormData を利用し Ajax で送信することです。

以下に .NET Framework 版の ASP.NET MVC5 アプリの例を書いておきます。これは一番上の画像を表示したものです。詳しい説明はコードの中のコメントに書きましたのでそれを見てください。手抜きでスミマセン。

Model

MultipleUploadModels クラスをアクションメソッドの引数に設定すれば、送信されてきた複数ファイルは PostedFiles プロパティにモデルバインドされます。CustomField プロパティはファイル以外の追加情報を一緒に送信するためのものです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace Mvc5App.Models
{
    public class MultipleUploadModels
    {
        public string CustomField { get; set; }
        public IList<HttpPostedFileBase> PostedFiles { get; set; }
    }
}

View

input type="file" 要素と input type="button" 要素(ボタン)を配置します。form 要素を生成するための html ヘルパー Html.BeginForm の引数(特に enctype = "multipart/form-data" とすること)に注意してください。

@section Scripts { ... } 内の JavaScript / jQuery のコードが HTML5 File API と FormData を利用し Ajax で複数フォルダ内の複数ファイルを送信するものです。このスクリプトがこの記事のキモです

@model Mvc5App.Models.MultipleUploadModels

@{
    ViewBag.Title = "MultipleUpload2";
}

<h2>MultipleUpload2</h2>

@using (Html.BeginForm("MultipleUpload2", "File", 
    FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.AntiForgeryToken()

    <div class="form-horizontal">
        <h4>MultipleUploadModels</h4>
        <hr />
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })

        <div class="form-group">
            @Html.LabelFor(model => model.PostedFiles, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                <input id="mutiplefileupload" type="file" 
                       name="postedfiles" multiple="multiple" />
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="button" id="ajaxUpload" value="Ajax Upload"
                       class="btn btn-default" />
                <br />
                <div id="result"></div>
            </div>
        </div>
        
    </div>
}

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

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

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

        $(function () {
            // 複数回送信する場合、送信し終わったら fd をクリアして最初から
            // 始めないと、次に送信するとき前回選んだファイルも送信されダブ
            // ってしまう。このコードはそこは未対応なので注意。

            // ブラウザの HTML5 File API サポートを確認
            if (window.File && window.FileReader && window.FileList) {

                // CSRF 用のトークンを含んだ FormData オブジェクトを取得
                var fd = new FormData(document.querySelector("form"));

                // input type="file" 要素のオブジェクトを取得
                var fileUpload = document.getElementById("mutiplefileupload");

                // input type="file" 要素でダイアログを開いてファイルを選択
                // し[開く]ボタンをクリックすると、その都度 change イベン
                // トが発生する。それにリスナ(下のコードの function )を
                // アタッチして FormData オブジェクトを操作する
                fileUpload.addEventListener('change', function (e) {
                    // files プロパティで FileList オブジェクトを取得
                    var filelist = fileUpload.files;

                    // 一回の操作で複数のファイルを選択できるので、以下
                    // のようにループを回して
                    for (let i = 0; i < filelist.length; i++) {
                        // File オブジェクトを FormData に追加していく
                        fd.append("postedfiles", filelist[i]);
                    }
                });

                // [Ajax Upload] ボタンクリックの処置
                $('#ajaxUpload').on('click', function (e) {

                    // 追加データを以下のようにして送信できる。フォーム
                    // データの一番最後に追加されて送信される
                    fd.append("CustomField", "This is some extra data");

                    $.ajax({
                        url: '/file/multipleupload2',
                        method: 'post',
                        data: fd,
                        processData: false, // jQuery にデータを処理させない
                        contentType: false  // contentType を設定させない
                    }).done(function (response) {
                        $("#result").empty;
                        $("#result").html(response);
                    }).fail(function (jqXHR, textStatus, errorThrown) {
                        $("#result").empty;
                        $("#result").text('textStatus: ' + textStatus +
                            ', errorThrown: ' + errorThrown);
                    });
                });
            } else {
                $("#result").empty;
                $("#result").text('File API がサポートされてません。');
            }
        });
        //]]>
    </script>
}

Controller / Action Method

Ajax 呼び出しのみ可能としていますが、普通に input type="submit" ボタンをクリックして post した場合でもファイルのアップロードはできます。ただしその場合は最後のファイル選択操作で選んだファイルのみが送信されます。

using System.Web;
using System.Web.Mvc;
using Mvc5App.Models;
using System.IO;
using System.Collections.Generic;

namespace Mvc5App.Controllers
{
    public class FileController : Controller
    {
        public ActionResult MultipleUpload2()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult MultipleUpload2(MultipleUploadModels model)
        {
            if (!Request.IsAjaxRequest())
            {
                return Content("Ajax 呼び出しのみ可能");
            }

            string result = "";
            string customFiled = model.CustomField;
            IList<HttpPostedFileBase> postedFiles = model.PostedFiles;

            // ファイルが選択されてない場合でも postedFiles は null にならないし、
            // postedFiles.Count は 0 にならない(1 になる)。従い、以下のコード
            // では制御が else に飛ぶことはないので注意
            if (postedFiles != null && postedFiles.Count > 0)
            {
                foreach (HttpPostedFileBase postedFile in postedFiles)
                {
                    if (postedFile != null && postedFile.ContentLength > 0)
                    {
                        // アップロードされたファイル名を取得。ブラウザが IE の
                        // 場合 postedFile.FileName はクライアント側でのフル
                        // パスになることがあるので Path.GetFileName を使う
                        string filename = Path.GetFileName(postedFile.FileName);

                        // 保存ホルダの物理パス\ファイル名
                        string path = Server.MapPath("~/UploadedFiles") + 
                                      "\\" + filename;

                        // アップロードされたファイルを保存
                        postedFile.SaveAs(path);

                        result += filename + " (" + postedFile.ContentType + 
                                  ") - " + postedFile.ContentLength.ToString() +
                                  " bytes アップロード完了<br />";
                    }
                }
                result += "CustomField の文字列: " + customFiled;
            }
            else
            {
                result = "ファイルアップロードに失敗しました";
            }

            return Content(result);
        }
    }
}

注意した方がよさそうと思う点を以下に書いておきます。

  1. 複数回送信する場合、送信し終わったら View の JavaScript のコードにある fd をクリアして最初から始めないと、次に送信するとき前回選んだファイルも送信されダブってしまいます。上のコードはそこは未対応なので注意してください。
  2. ファイルを選択しないで送信した場合でも、上の Controller / Action Method のコードにある postedFiles は null にならないし、postedFiles.Count は 0 になりません(1 になる)。従い、上のコードでは "ファイルアップロードに失敗しました" というエラーメッセージは出ません。  
  3. View のコードにある fd.append("CustomField", "This is some extra data"); でデータを送信して Model の CustomField プロパティにバインドできます。テキストボックスを配置してユーザーに入力してもらい送信してモデルバインドすることも考えましたが、送信前に FormData オブジェクトに append するのが難しく、それは今後の検討課題です。
  4. 上の 2 に書いたファイルを選択しないで送信した場合でも postedFile が null にならず、postedFiles.Count は 1 になる理由は、内容が空のパート(下の Fiddler による要求のキャプチャ画像の 2 つ目のパート)が送信されてくるからです。普通にファイルを選択して input type="submit" ボタンクリックで送信すれば空のパートはなくなりますが、この記事のように File API と FormData を JavaScript で細工するとそれが残ってしまいます。

    想像ですが、var fd = new FormData(document.querySelector("form")); で下の画像の 2 つ目までのパートが取得され、その後の操作で 3 つ目以降が追加されていくからであろうと思います。空のパートを削除する方法は分かりませんでした。

要求ヘッダとコンテンツ

Tags: , , , ,

Upload Download

ASP.NET Identity のロール管理 (CORE)

by WebSurfer 2020年6月1日 10:07

ASP.NET Core Identity のユーザー認証を実装した ASP.NET Core 3.1 MVC アプリに、管理者がロールの表示・追加・変更・削除およびユーザーへのロールのアサイン・解除を行う機能を実装してみます。

ロール情報の表示

先の記事「ASP.NET Identity のロール管理 (MVC)」で .NET Framework 版を書きましたが、この記事は Core 3.1 版です。

ベースとしているアプリは、先の記事「ASP.NET Identity のユーザー管理 (CORE)」で、管理者用のユーザー管理ページを実装したものです。

(1) ロールサービスの追加

ロールを使うにはサービスの追加が必要になります。以下のように、既存のコードに .AddRoles<IdentityRole>() を一行追加するだけで OK です。.NET Framework 版のアプリとは違って Migration 操作は不要です。

ロールサービスの追加

この記事のベースとしているアプリは「認証なし」で作成したあとスキャフォールディングで ASP.NET Core Identity を実装していますので、既存のコードは Areas/Identity フォルダの IdentityHostingStartup.cs ファイルにあります。上の画像がそれです。

認証を「個別のユーザーアカウント」としてプロジェクトを作成すると、サービス関係のコードは上の画像とは異なり、アプリケーションルート直下の Startup.cs フォルダに配置されます。

これだけでロールは使えるようになりますが、ロールの作成・変更・削除や、登録済みユーザーへのロールのアサイン・解除を行う機能は自力でコード書いて実装しなければなりません。その基本的な方法を以下に書きます。

(2) Model の作成

プロジェクトのルート直下の Models フォルダに RoleModel.cs というクラスファイルを追加し、その中に RoleModel クラスを定義して使います。(ファイル名、モデル名は任意です)

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace MySQLIdentity.Models
{
    // ロールの表示、追加、変更、削除用の Model
    // Index, Details, Create, Edit に使用
    public class RoleModel
    {
        public string Id { get; set; }

        [Required(ErrorMessage = "{0}は必須です。")]
        [StringLength(
          100,
          ErrorMessage = "{0} は {2} 文字以上",
          MinimumLength = 3)]
        [Display(Name = "ロール名")]
        public string Name { get; set; }
    }

    // 各ユーザーへのロールのアサイン状況一覧表示と
    // ロールのアサイン・解除用の Model
    // UserWithRoles, EditRoleAssignment に使用
    public class UserWithRoleInfo
    {
        public UserWithRoleInfo()
        {
            UserRoles = new List<RoleInfo>();
        }

        public string UserId { set; get; }

        [Display(Name = "ユーザー名")]
        public string UserName { set; get; }
        public string UserEmail { set; get; }

        public IList<RoleInfo> UserRoles { set; get; }
    }

    public class RoleInfo
    {
        public string RoleName { set; get; }
        public bool IsInThisRole { set; get; }
    }
}

RoleModel クラスはアクションメソッド Index, Details, Create, Edit でロールの表示、追加、変更、削除を行うために用いられる Model です。

フレームワークのライブラリに定義済みの IdentityRole クラスを利用することも可能です。

しかし、IdentityRole クラスを使うと (a) スキャフォールディング機能を使って View を生成できない(デザイナの「Razor ビューの追加」のダイアログで[モデルクラス(M):]の一覧に IdentityRole が現れない)、(b) 表示名を自由に付けられない・・・という理由で RoleModel クラスを定義して使うことにしました。

RoleModel クラスを使うと、Controller で Linq To Entities を使って取得した IdentityRole オブジェクトを RoleModel クラスに詰め替えなければならないという手間が増えますがやむを得ません。

UserWithRoleInfo, RoleInfo クラスは、アクションメソッド UserWithRoles, EditRoleAssignment で、ユーザーへのロールのアサイン状況の表示(この記事の一番上の画像参照)およびユーザーへのロールのアサイン・解除を行うために用いられる Model です。

(3) Controller の作成

.NET Framework 版の ASP.NET MVC5 アプリと違って、Core 版には組み込みの DI 機能があります。今回のケースではコントローラーを初期化する際に UserManager<MySQLIdentityUser> と RoleManager<IdentityRole> オブジェクトが注入されるよう実装します。

ロールの表示・追加・変更・削除を行うためコントローラーのコードは以下の通りです(この記事では Details アクションメソッドは実装していませんが、別の記事にロールに属するユーザー一覧を表示する機能を Details として実装してみましたので、興味があればそちらも見てください)。細かい注意点はコメントで入れましたのでそれを見てください。

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
// 追加
using Microsoft.AspNetCore.Identity;
using MySQLIdentity.Areas.Identity.Data;
using Microsoft.EntityFrameworkCore;
using MySQLIdentity.Models;


namespace MySQLIdentity.Controllers
{
    public class RoleController : Controller
    {
        // コントローラーを呼び出すと DI により自動的にコンストラクタに
        // UserManager<MySQLIdentityUser> と RoleManager<IdentityRole>
        // オブジェクトへの参照が渡される
        private readonly UserManager<MySQLIdentityUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;

        public RoleController(UserManager<MySQLIdentityUser> userManager,
                              RoleManager<IdentityRole> roleManager)
        {
            _userManager = userManager;
            _roleManager = roleManager;
        }

        // GET: Role/Index
        // Model は RoleModel
        public async Task<IActionResult> Index()
        {
            var roles = from r in _roleManager.Roles
                        orderby r.Name
                        select new RoleModel
                        {
                            Id = r.Id,
                            Name = r.Name
                        };

            return View(await roles.ToListAsync());
        }


        // GET: Role/Details/5・・・省略
        // ロール名以外の情報は含まれないので不要


        // GET: Role/Create
        // Model は RoleModel クラス
        public IActionResult Create()
        {
            return View();
        }

        // POST: Role/Create
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create(
                            [Bind("Id,Name")] RoleModel model)
        {
            if (ModelState.IsValid)
            {
                // ユーザーが入力したロール名を model.Name から
                // 取得し IdentityRole オブジェクトを生成
                var role = new IdentityRole { Name = model.Name };

                // 上の IdentityRole から新規ロールを作成・登録
                var result = await _roleManager.CreateAsync(role);

                if (result.Succeeded)
                {
                    // 登録に成功したら Role/Index にリダイレクト
                    return RedirectToAction("Index", "Role");
                }

                // result.Succeeded が false の場合 ModelSate にエ
                // ラー情報を追加しないとエラーメッセージが出ない。
                // Register.cshtml.cs のものをコピー
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, 
                                             error.Description);
                }
            }

            // ロールの登録に���敗した場合、登録画面を再描画
            return View(model);
        }

        // GET: Role/Edit/5
        // Edit でできるのはロール名の変更のみ
        // Model は RoleModel クラス
        public async Task<IActionResult> Edit(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var target = await _roleManager.FindByIdAsync(id);

            if (target == null)
            {
                return NotFound();
            }

            RoleModel model = new RoleModel
            { 
                Name = target.Name 
            };

            return View(model);
        }

        // POST: Role/Edit/5
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(string id, 
                        [Bind("Id,Name")] RoleModel model)
        {
            if (id == null)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                var target = await _roleManager.FindByIdAsync(id);
                if (target == null)
                {
                    return NotFound();
                }

                // ユーザーが入力したロール名を model.Name から取得し
                // て IdentityRole の Name を書き換え
                target.Name = model.Name;

                // Name を書き換えた IdentityRole で更新をかける
                var result = await _roleManager.UpdateAsync(target);

                if (result.Succeeded)
                {
                    // 更新に成功したら Role/Index にリダイレクト
                    return RedirectToAction("Index", "Role");
                }

                // result.Succeeded が false の場合 ModelSate にエ
                // ラー情報を追加しないとエラーメッセージが出ない。
                // Register.cshtml.cs のものをコピー
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, 
                                             error.Description);
                }
            }

            // 更新に失敗した場合、編集画面を再描画
            return View(model);
        }

        // GET: Role/Delete/5
        // 階層更新が行われているようで、ユーザーがアサインされて
        // いるロールも削除可能。
        // Model は RoleModel
        public async Task<IActionResult> Delete(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var role = await _roleManager.FindByIdAsync(id);

            if (role == null)
            {
                return NotFound();
            }

            var model = new RoleModel
            {
                Id = role.Id,
                Name = role.Name
            };

            return View(model);
        }

        // POST: Role/Delete/5
        // 上の Delete(string id) と同シグネチャのメソッド
        // は定義できないので、メソッド名を変えて、下のよう
        // に ActionName("Delete") を設定する
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var role = await _roleManager.FindByIdAsync(id);

            if (role == null)
            {
                return NotFound();
            }

            // ユーザーがアサインされているロールも以下の一行で
            // 削除可能。内部で階層更新が行われているらしい。
            var result = await _roleManager.DeleteAsync(role);

            if (result.Succeeded)
            {
                // 削除に成功したら Role/Index にリダイレクト
                return RedirectToAction("Index", "Role");
            }

            // result.Succeeded が false の場合 ModelSate にエ
            // ラー情報を追加しないとエラーメッセージが出ない。
            // Register.cshtml.cs のものをコピー
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, 
                                         error.Description);
            }
            // 削除に失敗した場合、削除画面を再描画
            var model = new RoleModel
            {
                Id = role.Id,
                Name = role.Name
            };

            return View(model);
        }
    }
}

登録済みユーザーへのロールのアサイン状況の表示、個々のユーザーへのロールの追加・削除を行うためのアクションメソッドは以下の通りです。実際は、上のコードの RoleController 内に実装しています(分けたのは単にその方が読みやすいと思ったからです)。

// 以下は:
// (1) UserWithRoles で登録済みユーザーの一覧と各ユーザーへの
//     ロールのアサイン状況を表示し、
// (2) Edit ボタンクリックで EditRoleAssignment に遷移し、当該
//     ユーザーへのロールのアサインを編集して保存
// ・・・を行うアクションメソッド。

// GET: Role/UserWithRoles
// ユーザー一覧と各ユーザーにアサインされているロールを表示
// Model は UserWithRoleInfo クラス
public async Task<IActionResult> UserWithRoles()
{
    var model = new List<UserWithRoleInfo>();

    // ToListAsync() を付与してここで DB からデータを取得して
    // DataReader を閉じておかないと、下の IsInRole メソッド
    // でエラーになるので注意
    var users = await _userManager.Users.
                OrderBy(user => user.UserName).ToListAsync();
    var roles = await _roleManager.Roles.
                OrderBy(role => role.Name).ToListAsync();

    foreach (MySQLIdentityUser user in users)
    {
        var info = new UserWithRoleInfo
        {
            UserId = user.Id,
            UserName = user.UserName,
            UserEmail = user.Email
        };

        foreach (IdentityRole role in roles)
        {
            RoleInfo roleInfo = new RoleInfo();
            roleInfo.RoleName = role.Name;
            roleInfo.IsInThisRole = await _userManager.
                                    IsInRoleAsync(user, role.Name);
            info.UserRoles.Add(roleInfo);
        }
        model.Add(info);
    }

    return View(model);
}

// GET: Role/EditRoleAssignment/Id
// 指定 Id のユーザーのロールへのアサインの編集
// Model は UserWithRoleInfo クラス
public async Task<IActionResult> EditRoleAssignment(string id)
{
    if (id == null)
    {
        return NotFound();
    }

    var user = await _userManager.FindByIdAsync(id);
    if (user == null)
    {
        return NotFound();
    }

    var model = new UserWithRoleInfo
    {
        UserId = user.Id,
        UserName = user.UserName,
        UserEmail = user.Email
    };

    // ToListAsync() を付与しておかないと下の IsInRole メソッド
    // で DataReader が閉じてないというエラーになる
    var roles = await _roleManager.Roles.
                  OrderBy(role => role.Name).ToListAsync();

    foreach (IdentityRole role in roles)
    {
        RoleInfo roleInfo = new RoleInfo();
        roleInfo.RoleName = role.Name;
        roleInfo.IsInThisRole = await _userManager.
                                IsInRoleAsync(user, role.Name);
        model.UserRoles.Add(roleInfo);
    }

    return View(model);
}

// POST: Role/EditRoleAssignment/Id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditRoleAssignment(string id, 
  [Bind("UserId,UserName,UserEmail,UserRoles")] UserWithRoleInfo model)
{
    if (id == null)
    {
        return NotFound();
    }

    // IsInRoleAsync, AddToRoleAsync, RemoveFromRoleAsync メソッド
    // の引数が MVC5 とは異なり、id ではなく MySQLIdentityUser が
    // 必要なのでここで取得しておく
    var user = await _userManager.FindByIdAsync(id);
    if (user == null)
    {
        return NotFound();
    }

    if (ModelState.IsValid)
    {
        IdentityResult result;

        foreach (RoleInfo roleInfo in model.UserRoles)
        {
            // id のユーザーが roleInfo.RoleName のロールに属して
            // いるか否か。以下でその情報が必要。
            bool isInRole = await _userManager.
                            IsInRoleAsync(user, roleInfo.RoleName);

            // roleInfo.IsInThisRole には編集画面でロールのチェッ
            // クボックスのチェック結果が格納されている
            if (roleInfo.IsInThisRole)
            {
                // チェックが入っていた場合

                // 既にロールにアサイン済みのユーザーを AddToRole
                // するとエラーになるので以下の判定が必要
                if (isInRole == false)
                {
                    result = await _userManager.AddToRoleAsync(user, 
                                                      roleInfo.RoleName);
                    if (!result.Succeeded)
                    {
                        // result.Succeeded が false の場合 ModelSate にエ
                        // ラー情報を追加しないとエラーメッセージが出ない。
                        // Register.cshtml.cs のものをコピー
                        foreach (var error in result.Errors)
                        {
                            ModelState.AddModelError(string.Empty, 
                                                     error.Description);
                        }
                        return View(model);
                    }
                }
            }
            else
            {
                // チェックが入っていなかった場合

                // ロールにアサインされてないユーザーを
                // RemoveFromRole するとエラーになるので以下の
                // 判定が必要
                if (isInRole == true)
                {
                    result = await _userManager.
                             RemoveFromRoleAsync(user, roleInfo.RoleName);
                    if (!result.Succeeded)
                    {
                        foreach (var error in result.Errors)
                        {
                            ModelState.AddModelError(string.Empty, 
                                                     error.Description);
                        }
                        return View(model);
                    }
                }
            }
        }

        // 編集に成功したら Role/UserWithRoles にリダイレクト
        return RedirectToAction("UserWithRoles", "Role");
    }

    // 編集に失敗した場合、編集画面を再描画
    return View(model);
}

(4) View の作成

View は Controller のアクションメソッドを右クリックして[ビューの追加(D)...]からスキャフォールディング機能を使って生成します。

Razor ビューの追加

アクションメソッド EditRoleAssignment に対応する View は自動生成されるコードだけでは不十分でいろいろ追加・修正が必要です。以下にその完成版のコードを載せておきます。

@model MySQLIdentity.Models.UserWithRoleInfo

@{
    ViewData["Title"] = "EditRoleAssignment";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>EditRoleAssignment</h1>

<h4>UserWithRoleInfo</h4>
<hr />
<form asp-action="EditRoleAssignment">
    <table class="table">
        <thead>
            <tr>
                <th>
                    @Html.DisplayNameFor(model => model.UserName)
                </th>
                @foreach (var role in Model.UserRoles)
                {
                    <th>
                        @Html.DisplayFor(modelItem => role.RoleName)
                    </th>
                }
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>
                    @Html.DisplayFor(model => model.UserName)
                    @Html.HiddenFor(model => model.UserName)
                    @Html.HiddenFor(model => model.UserEmail)
                    @Html.HiddenFor(model => model.UserId)
                </td>
                @for (int i = 0; i < Model.UserRoles.Count; i++)
                {
                    <td>
                        @Html.EditorFor(model => model.UserRoles[i].IsInThisRole)
                        @Html.HiddenFor(model => model.UserRoles[i].RoleName)
                    </td>
                }

            </tr>
        </tbody>
    </table>
    <div class="form-group">
        <input type="submit" value="Save" class="btn btn-primary" />
    </div>
</form>

<div>
    <a asp-action="UserWithRoles">Back to List</a>
</div>

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

上の View のコードで注意すべき点は、コレクション(上の例では Model.UserRoles で取得できる RoleInfo クラスのコレクション) についてはレンダリングされる html 要素の name 属性が連番のインデックスを含むようにすることです。具体的には、name="prefix[index].Property" というパターンにします。

そうしないとモデルバインディングがうまくいきません。 その理由など詳しくは先の記事「コレクションのデータアノテーション検証」を見てください。

Tags: , , ,

CORE

ASP.NET Identity のユーザー管理 (CORE)

by WebSurfer 2020年5月31日 11:45

ASP.NET Core Identity ベースのユーザー認証を実装した ASP.NET Core 3.1 MVC アプリで、管理者がユーザー情報の追加、変更、削除を行う機能を実装する例を書きます。

注: 下に出てくる「ハンドル名 HandleName」はプロファイル情報として後から追加したものでデフォルトでは含まれないので注意してください。

ユーザー管理画面

(登録ユーザーが多くなってくると、上の画面のような全員の一覧表示では管理が難しく、何らかの絞り込み機能が必要と思いますが、それは次の課題ということで・・・  手抜きですみません)

先の記事「ASP.NET Identity のユーザー管理 (MVC)」で .NET Framework 版の ASP.NET MVC5 の例を書きましたが、この記事は Core 3.1 版です。

ベースとなるのは、Visual Studio 2019 のテンプレートを使って、先の記事「ASP.NET Identity で MySQL 利用 (CORE 版)」のステップ (1)、(2) の手順で作成した ASP.NET Core 3.1 MVC アプリです。そのプロジェクトはデータベースに MySQL を使っていますが、この記事の手順は SQL Server でも同じです。

(1) Model の作成

プロジェクトのルート直下の Models フォルダに UserModel.cs というクラスファイルを追加し(名前は任意)、その中に以下のクラスを定義して使います。

using System;
using System.ComponentModel.DataAnnotations;

namespace MySQLIdentity.Models
{
    // UserController の Index, Details, Delete 用
    public class UserModel
    {
        public string Id { get; set; }

        [Display(Name = "ユーザー名")]
        public string UserName { get; set; }

        [Display(Name = "ハンドル名")]
        public string HandleName { get; set; }

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

        [Display(Name = "メール確認済")]
        public bool EmailConfirmed { get; set; }

        [Display(Name = "電話番号")]
        public string PhoneNumber { get; set; }

        [Display(Name = "電話番号確認済")]
        public bool PhoneNumberConfirmed { get; set; }

        [Display(Name = "二要素認証")]
        public bool TwoFactorEnabled { get; set; }

        [Display(Name = "ロック有効化")]
        public bool LockoutEnabled { get; set; }

        [Display(Name = "ロック終了日時")]
        public DateTimeOffset? LockoutEnd { get; set; }

        [Display(Name = "アクセス失敗数")]
        public int AccessFailedCount { get; set; }
    }

    // UserController の Create 用
    public class RegisterViewModel
    {
        [Required(ErrorMessage = "{0}は必須です。")]
        [EmailAddress]
        [Display(Name = "メールアドレス")]
        public string Email { get; set; }

        [Required(ErrorMessage = "{0}は必須です。")]
        [StringLength(100, ErrorMessage =
            "{0}は{2}から{1}文字の範囲で設定してください。",
            MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "パスワード")]
        public string Password { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "パスワード確認")]
        [Compare("Password", ErrorMessage = "確認パスワードが一致しません。")]
        public string ConfirmPassword { get; set; }
    }

    // UserController の Edit 用
    // 上の RegisterViewModel を使うとパスワードを入力しない場合は検証に
    // 通らない。違いは Password プロパティの [Required] を外しただけ
    public class EditViewModel
    {
        [Required(ErrorMessage = "{0}は必須です。")]
        [EmailAddress]
        [Display(Name = "メールアドレス")]
        public string Email { get; set; }

        [StringLength(100, ErrorMessage =
            "{0}は{2}から{1}文字の範囲で設定してください。",
            MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "パスワード")]
        public string Password { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "パスワード確認")]
        [Compare("Password", ErrorMessage = "確認パスワードが一致しません。")]
        public string ConfirmPassword { get; set; }
    }
}

UserModel クラスは Index, Details, Delete 用の「ビューモデル」です。(「ビューモデル」は Controller から View にデータを渡すために使われます。詳しくは先の記事「ASP.NET MVC の Model」を見てください)

UserModel クラスに定義されているプロパティは、IdentityUser クラスを継承した MySQLIdentityUser カスタムクラスと同じです。(上に書いたプロジェクトの作成のステップ (1)、(2) で自動生成される Areas/Identity/Data フォルダの MySQLIdentityUser.cs ファイルに定義されています)

なので、MySQLIdentityUser カスタムクラスを「ビューモデル」として使うこともできます。しかし、(a) スキャフォールディング機能を使って View を生成できない(デザイナの「Razor ビューの追加」のダイアログで[モデルクラス(M):]の一覧に MySQLIdentityUser が現れない・・・理由不明)、(b) 表示名を自由に付けられない(プロパティ名と同じになる)・・・という理由で UserModel クラスを定義して使うことにしました。

そうすると、Controller で Linq To Entities を使って取得した MySQLIdentityUser オブジェクトを UserModel クラスに詰め替えなければならないという手間が増えますがやむを得ません。

詰め替えの手間を減らすため、先の記事「EDM にデータアノテーション属性を付与」に書いたようなメタデータクラスを作って対応することもトライしましたが、無駄な努力でした。

(2) Controller の作成

.NET Framework 版の ASP.NET MVC5 との大きな違いは、Core 版には組み込みの DI 機能があることでしょうか。今回のケースではコントローラーを初期化する時 UserManager<MySQLIdentityUser> オブジェクトを注入するようにしています。

コントローラーに UserManager<MySQLIdentityUser> オブジェクトへの参照を保持するフィールドを追加し、コンストラクタに UserManager<MySQLIdentityUser> オブジェクトへの参照を受け取る引数とそれをフィールドに代入するコードを書けば、あとは Areas/Identity フォルダに生成された IdentityHostingStartup.cs ファイル内の ConfigureServices メソッドで登録されたサービスが面倒を見てくれるようです。(詳しいメカニズムは調べ切れてませんが・・・)

ASP.NET Core Identity のデータベースに対し CRUD 操作を行うためのコントローラーのコードは以下の通りです。細かい注意点はコメントとして入れましたのでそちらを見てください。

using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

// 追加
using Microsoft.AspNetCore.Identity;
using MySQLIdentity.Areas.Identity.Data;
using Microsoft.EntityFrameworkCore;
using MySQLIdentity.Models;

namespace MySQLIdentity.Controllers
{
    public class UserController : Controller
    {
        // Register.cshtml.cs のコードにならって以下のフィールドと
        // コンストラクタを追加。
        // 組み込みの DI 機能を利用して、コントローラーを初期化する際
        // UserManager<MySQLIdentityUser> オブジェクトを注入するため
        private readonly UserManager<MySQLIdentityUser> _userManager;

        public UserController(UserManager<MySQLIdentityUser> userManager)
        {
            _userManager = userManager;
        }


        // GET: User/Index
        // Model は UserModel
        public async Task<IActionResult> Index()
        {
            var users = from user in _userManager.Users
                        orderby user.UserName
                        select new UserModel
                        {
                            Id = user.Id,
                            UserName = user.UserName,
                            HandleName = user.HandleName,
                            Email = user.Email,
                            EmailConfirmed = user.EmailConfirmed,
                            PhoneNumber = user.PhoneNumber,
                            PhoneNumberConfirmed = user.PhoneNumberConfirmed,
                            TwoFactorEnabled = user.TwoFactorEnabled,
                            LockoutEnabled = user.LockoutEnabled,
                            LockoutEnd = user.LockoutEnd,
                            AccessFailedCount = user.AccessFailedCount
                        };

            return View(await users.ToListAsync());
        }

        // GET: User/Details/5
        // Model は UserModel
        // Core には HttpNotFound, HttpStatusCodeResult は無いので
        // 代わりに NotFound() を使う
        // 引数の id は string 型にすること
        public async Task<IActionResult> Details(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var user = await _userManager.FindByIdAsync(id);

            if (user == null)
            {
                return NotFound();
            }

            var model = new UserModel
            {
                Id = user.Id,
                UserName = user.UserName,
                HandleName = user.HandleName,
                Email = user.Email,
                EmailConfirmed = user.EmailConfirmed,
                PhoneNumber = user.PhoneNumber,
                PhoneNumberConfirmed = user.PhoneNumberConfirmed,
                TwoFactorEnabled = user.TwoFactorEnabled,
                LockoutEnabled = user.LockoutEnabled,
                LockoutEnd = user.LockoutEnd,
                AccessFailedCount = user.AccessFailedCount
            };

            return View(model);
        }

        // GET: User/Create
        // Model は Model/UserModel.cs の RegisterViewModel
        public IActionResult Create()
        {
            return View();
        }

        // POST: User/Create
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create(
            [Bind("Email,Password,ConfirmPassword")] RegisterViewModel model)
        {
            // PasswordValidator による判定の結果は ModelState
            // には反映されないので注意(下記 result で判定)
            if (ModelState.IsValid)
            {
                // ユーザー入力のメールアドレスを UserName, Email,
                // HandleName プロパティに設定。さらに、ここで追加
                // したアカウントは Email Confirmation 無しで使用で
                // きるよう EmailConfirmed を true にする
                var user = new MySQLIdentityUser
                {
                    UserName = model.Email,
                    Email = model.Email,
                    HandleName = model.Email,
                    EmailConfirmed = true
                };

                // 上の user とユーザーが入力したパスワードで新規
                // ユーザーを作成・登録
                var result = await _userManager.
                    CreateAsync(user, model.Password);

                // ユーザー作成・登録の成否を result.Succeeded で
                // 判定。PasswordValidator の判定結果も result に
                // 反映される
                if (result.Succeeded)
                {
                    // 登録に成功したら User/Index にリダイレクト
                    return RedirectToAction("Index", "User");
                }
                // result.Succeeded が false の場合 ModelSate にエ
                // ラー情報を追加しないとエラーメッセージが出ない。
                // Register.cshtml.cs のものをコピー
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }

            // ユーザー登録に失敗した場合、登録画面を再描画
            return View(model);
        }

        // GET: User/Edit/5
        // ここで更新できるのは Email とパスワードのみ。
        // UseName は POST する際 Email と同じに設定する。
        // Model は Models/UserModel.cs の EditViewModel
        // 引数の id は string 型にすること
        public async Task<IActionResult> Edit(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var target = await _userManager.FindByIdAsync(id);

            if (target == null)
            {
                return NotFound();
            }

            EditViewModel model = new EditViewModel()
            {
                Email = target.Email
            };

            return View(model);
        }

        // POST: User/Edit/5
        // UserName をソルトに使っていてパスワードだけもしくは
        // UserName だけを更新するのは NG かと思っていたが問題
        // なかった。(実際どのように対処しているかは不明)
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(string id, 
            [Bind("Email,Password,ConfirmPassword")] EditViewModel model)
        {
            if (id == null)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                var target = await _userManager.FindByIdAsync(id);
                if (target == null)
                {
                    return NotFound();
                }

                target.Email = model.Email;
                target.UserName = model.Email;

                // 新パスワードを入力した場合はパスワードも更新する
                if (!string.IsNullOrEmpty(model.Password))
                {
                    // MVC5 と違って PasswordValidator プロパティはない
                    // PasswordValidators で IList<IPasswordValidator<TUser>>
                    // を取得できる。PasswordValidators[0] で検証可能
                    // (ホントにそれで良いのかどうかは分からないが)
                    // ValidateAsync メソッドの引数は MVC5 と違うので注意
                    var resultPassword = await _userManager.PasswordValidators[0].
                        ValidateAsync(_userManager, target, model.Password);

                    if (resultPassword.Succeeded)
                    {
                        // 検証 OK の場合、入力パスワードをハッシュ。
                        // HashPassword メソッドの引数は MVC5 とは異なる
                        var hashedPassword = _userManager.PasswordHasher.
                            HashPassword(target, model.Password);
                        target.PasswordHash = hashedPassword;
                    }
                    else
                    {
                        // 検証 NG の場合 ModelSate にエラー情報を
                        // 追加して編集画面を再描画
                        // Register.cshtml.cs のものをコピー
                        foreach (var error in resultPassword.Errors)
                        {
                            ModelState.AddModelError(string.Empty, error.Description);
                        }
                        return View(model);
                    }
                }

                var resultUpdate = await _userManager.UpdateAsync(target);

                if (resultUpdate.Succeeded)
                {
                    // 更新に成功したら User/Index にリダイレクト
                    return RedirectToAction("Index", "User");
                }
                // Register.cshtml.cs のものをコピー
                foreach (var error in resultUpdate.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }

            // 更新に失敗した場合、編集画面を再描画
            return View(model);
        }

        // GET: User/Delete/5
        // Model は UserModel
        // 階層更新が行われているようでロールがアサインされている
        // ユーザーも削除可
        public async Task<IActionResult> Delete(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var u = await _userManager.FindByIdAsync(id);

            if (u == null)
            {
                return NotFound();
            }

            var model = new UserModel
            {
                Id = u.Id,
                UserName = u.UserName,
                HandleName = u.HandleName,
                Email = u.Email,
                EmailConfirmed = u.EmailConfirmed,
                PhoneNumber = u.PhoneNumber,
                PhoneNumberConfirmed = u.PhoneNumberConfirmed,
                TwoFactorEnabled = u.TwoFactorEnabled,
                LockoutEnabled = u.LockoutEnabled,
                AccessFailedCount = u.AccessFailedCount
            };

            return View(model);
        }

        // POST: User/Delete/5
        // 上の Delete(string id) と同シグネチャのメソッドは
        // 定義できないので、メソッド名を変えて、下のように
        // ActionName("Delete") を設定する
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var target = await _userManager.FindByIdAsync(id);

            if (target == null)
            {
                return NotFound();
            }

            // ロールがアサインされているユーザーも以下の一行
            // で削除可能。内部で階層更新が行われているらしい。
            var result = await _userManager.DeleteAsync(target);

            if (result.Succeeded)
            {
                // 削除に成功したら User/Index にリダイレクト
                return RedirectToAction("Index", "User");
            }

            // Register.cshtml.cs のものをコピー
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, error.Description);
            }

            return View(target);
        }
    }
}

(3) View の作成

View は Controller のアクションメソッドを右クリックして[ビューの追加(D)...]からスキャフォールディング機能を使って生成します。

Razor ビューの追加

スキャフォールディング機能で自動生成された Index.cshtml のコードだけ以下に書いておきます。(生成後、一部手を加えなければならないところもありますので注意してください。この記事の例では ActionLink ヘルパーメソッドの第 3 引数を下のように修正しました)

@model IEnumerable<MySQLIdentity.Models.UserModel>

@{
    ViewData["Title"] = "Index";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Index</h1>

<p>
    <a asp-action="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.UserName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.HandleName)
            </th>
           <th>
                @Html.DisplayNameFor(model => model.PhoneNumber)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.UserName)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.HandleName)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.PhoneNumber)
            </td>
            <td>
                @Html.ActionLink("Edit", "Edit", new { id=item.Id }) |
                @Html.ActionLink("Details", "Details", new { id=item.Id }) |
                @Html.ActionLink("Delete", "Delete", new { id=item.Id })
            </td>
        </tr>
}
    </tbody>
</table>

この記事の一番上にある画像が Index.cshtml で描画されたものです。Details, Create, Edit, Delete 用の View もスキャフォールディング機能で生成できます。

Tags: , , ,

CORE

About this blog

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

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar