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)...]からスキャフォールディング機能を使って生成します。
スキャフォールディング機能で自動生成された 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 もスキャフォールディング機能で生成できます。