WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

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

by WebSurfer 1. June 2020 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 です。

(2) Controller の作成

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

ロールの表示・追加・変更・削除を行うためコントローラーのコードは以下の通りです(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);
}

(3) 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 31. May 2020 11:45

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

ユーザー管理画面

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

先の記事「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

プロファイル情報の追加 (CORE 版)

by WebSurfer 23. May 2020 14:09

ASP.NET Core 3.1 MVC アプリに ASP.NET Core Identity ベースのユーザー認証を実装して、プロファイル情報(カスタムユーザーデータ)としてハンドル名を追加し、ログイン時にページのヘッダ右上にデフォルトで表示される Email に代えてハンドル名を表示する方法を書きます。

ハンドル名の表示

プロファイル情報を追加するには Microsoft のドキュメント Add, download, and delete custom user data to Identity in an ASP.NET Core project の Run the Identity scaffolder セクションに書いてあるように、IdentityUser を継承した xxxxxUser クラス(xxxxx はデフォルトではプロジェクト名)を作ってそれを使う必要があります。

そのようなプロジェクトの作成手順は、先の記事「ASP.NET Identity で MySQL 利用 (CORE 版)」のステップ「(1) プロジェクトの作成」とステップ「(2) ASP.NET Core Identity の実装」を見てください。

以下の説明は Visual Studio 2019 でプロジェクト名を MySQLIdentity として作成した ASP.NET Core 3.1 MVC アプリをベースにしています。そのプロジェクトは、記事に書いたとおりデータベースは MySQL を使うように変更していますが、以下のプロファイル情報の追加手順は SQL Server でも同じです。

(1) HandleName プロパティの追加

プロジェクトの Areas/Identity/Data/MySQLIdentityUser.cs ファイルに IdentityUser を継承した MySQLIdentityUser クラスが定義されています(注: MySQLIdentity の部分はプロジェクト名になります。すなわちプロジェクトによって変わります)

プロジェクト作成時点では MySQLIdentityUser クラスの中身は空です。それに HandleName プロパティを追加します。以下のような感じです。

using Microsoft.AspNetCore.Identity;

// 追加
using System.ComponentModel.DataAnnotations;

namespace MySQLIdentity.Areas.Identity.Data
{
    public class MySQLIdentityUser : IdentityUser
    {
        // プロファイル情報としてハンドル名を追加
        [PersonalData]
        [Display(Name = "ハンドル名")]
        [Required(ErrorMessage = "{0}は必須です。")]
        [StringLength(128, ErrorMessage = "{0}は{1}文字以内で入力してください。")]
        public string HandleName { get; set; }
    }
}

そのあと Migration 操作を行うと Entity Framework Code First の機能によりデータベースには HandleName フィールドが追加されます。HandleName フィールドはデータアノテーション属性の Required により NULL 不可に、StringLength の引数 128 により varchar(128) になります。

後で気が付いたのですが、表示名や検証のエラーメッセージは Razor ページで別に定義したモデルに付与した属性のものを使うので、ここで上のように DisplayName 属性やエラーメッセージを付与しても意味はなさそうです。しかし、害もなさそうなのでそのままにしています。

PersonalData 属性は、参考にした Microsoft のドキュメントによると "it's automatically available for download and deletion. Making the data able to be downloaded and deleted helps meet GDPR requirements." とのことです。

その "downloaded" というのは Manage ページでユーザー情報の JSON 文字列を PersonalData.json という名前のファイルにしてダウンロードする機能で、その中にハンドル名のデータが含まれるようになります。PersonalData 属性を付与しないとプロファイル情報は含まれないのかは未検証です。

"deleted" はユーザー情報がすべてデータベースから削除されるという動きになります。PersonalData 属性を付与しようがしまいが全削除には変わりはないと思われるのですが・・・

(2) Add-Migration

Visual Studio のパッケージマネージャーコンソールで Add-Migration CustomUserData コマンドを実行します。CustomUserData という名前は任意です。

成功すると Migrations フォルダに xxxxx_CustomUserData.cs という名前(xxxxx は作成日時)のクラスファイルが生成され、それに以下のようなコードが含まれているはずです。

using Microsoft.EntityFrameworkCore.Migrations;

namespace MySQLIdentity.Migrations
{
    public partial class CustomUserData : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.AddColumn<string>(
                name: "HandleName",
                table: "AspNetUsers",
                maxLength: 128,
                nullable: false,
                defaultValue: "");
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropColumn(
                name: "HandleName",
                table: "AspNetUsers");
        }
    }
}

上のコードは、既にデータベースには ASP.NET Core Identity 用のテーブルはすべて生成済みという状態にプロファイル情報 HandleName を追加した場合です。プロジェクト作成直後のデータベースには何もない状態で Add-Migration を実行した場合に生成されるコートとは異なります。

(3) Update-Database

Visual Studio のパッケージマネージャーコンソールで Update-Database コマンドを実行します。成功すると ASP.NET Core Identity 用のデータベースの aspnetusers テーブルに HandleName フィールドが追加されます。

aspnetusers テーブル

この記事ではデータベースは SQL Server ではなく MySQL を利用しています。nvarchar でなく varchar となっているのは MySQL の事情です。

MySQL のドキュメント 10.3.7 The National Character Set によると、"MySQL uses utf8 as this predefined character set" なのでどちらでも同じなのだそうです。ちなみに create 句で nvarchar と指定しても、生成されるのは varchar になります。

(4) Register ページの修正

Areas/Identity/Pages/Account/Reguster.cshtml.cs の OnPostAsync メソッドに、以下の画像の赤枠のコードを追加します。登録時にはハンドル名は自動的に Email と同じになるようにするものです。

Register ページの修正

上のステップ (1) で HandleName プロパティには Rquired 属性を付与しましたので、aspnetusers テーブルの HandleName フィールドは NULL 不可になっています。ステップ (3) の画像を見てください。

そのため、新規ユーザーの登録を行う際 HandleName を入力しないと CreateAsync でエラーとなります。と言って、登録時の手間を増やすとユーザーに面倒がられると思われます。

なので、登録時には自動的に HandleName は Email と同じになるようにし、後で変更できるようにしました。

(5) ハンドル名の変更機能を実装

ログイン後、ページのヘッダの右上のユーザー名(デフォルトで Email)をクリックすると Manage ページに遷移しますが、そこでユーザーがハンドル名を任意に変更できるようにします。

まず Areas/Identity/Pages/Account/Manage/Index.cshtml.cs の InputModel クラスへのハンドル名用のプロパティの追加と、LoadAsync ヘ���パーメソッドでのハンドル名の InputModel への設定のためのコードを追加します。入力できるハンドル名の長さはとりあえず 30 文字に制限してみました(当該 input 要素に maxlength="30" が設定されます)。

InputModel クラスの処理

同じく Index.cshtml.cs のコードの OnPostAsync メソッドにハンドル名の変更をデータベースに反映するコードを追加します。

ハンドル名の変更処理

次に Areas/Identity/Pages/Account/Manage/Index.cshtml にハンドル名入力用のテキストボックスを追加します。

ハンドル名用テキストボックス追加

(6) ハンドル名の変更操作

ユーザーがログインするとページのヘッダの右上に Hello xxxxx! と表示され、それが Manage ページへのリンクになります。それをクリックするとプロファイル情報の編集画面に遷移します。

上のステップ (5) で加えたコードの修正により、画面にはハンドル名入力用のテキストボックスが表示されています。

ハンドル名の変更

初期画面では[ハンドル名]テキストボックスには[Username]テキストボックスと同じメールアドレスが表示されます。それを任意の名前に設定し[Save]ボタンをクリックするとハンドル名は変更されます。

上の画像は初期画面のメールアドレスを WebSurfer というハンドル名に変更した後のものです。

(7) ClaimsIdentity へ追加

ページのヘッダの右上に Hello xxxxx! と表示される xxxxx にはデフォルトではメールアドレスが表示されていますが(上のステップ (6) の画像参照)、それをハンドル名に変更します。結果、この記事の一番上の画像のようになります。

プロファイル情報を Claim として ClaimsIdentity オブジェクトに追加し、拡張メソッドを使って ClaimsIdentity オブジェクトからハンドル名を取得・表示するようにしています。

ClaimsIdentity オブジェクトにハンドル名を追加すると、認証クッキーにハンドル名が含まれるようになります。認証クッキーからハンドル名を取得できれば、毎回データベースにクエリを投げて取得するより負荷は軽い(であろう)というのが ClaimsIdentity を使う理由です。

プロファイル情報を追加・取得するための詳しい手順は、先の記事「ASP.NET Core MVC の ClaimsIdentity」に書きましたので見てください。

注意すべき変更点は、(1) その記事は PhoneNumber を使っていますが、それを HandleName に変更、(2) IdentityUser を MySQLIdentity.Areas.Identity.Data 名前空間の MySQLIdentityUser に変更、(3) ClaimTypes の HomePhone を GivenName に変更(GivenName でなくても良いですが Name はすでに使われているようで NG)・・・です。

Tags: , ,

CORE

About this blog

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

Calendar

<<  June 2020  >>
MoTuWeThFrSaSu
25262728293031
1234567
891011121314
15161718192021
22232425262728
293012345

View posts in large calendar