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)...]からスキャフォールディング機能を使って生成します。
アクションメソッド 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" というパターンにします。
そうしないとモデルバインディングがうまくいきません。
その理由など詳しくは先の記事「コレクションのデータアノテーション検証」を見てください。